mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 20:18:29 +01:00
ci(pr-validation): validate PR title + body against the template
New .github/workflows/pr-validation.yml (pull_request_target, like seerr, so it works on fork PRs without checking out fork code): moves the Conventional-Commits title check out of the quality gate and adds a PR template check (scripts/check-pr-template.mjs) — Description/Ticket/Testing filled, contribution + AI-disclosure boxes ticked (maintainers bypass AI), and Screenshots required when the PR changes UI (.tsx under app/ or components/). Posts a sticky comment + 'blocked: template' label on failure, clears on success; skips bots + synchronize. Robust comment stripping (CodeQL-safe). Inspired by seerr's pr-validation.
This commit is contained in:
116
scripts/check-pr-template.mjs
Normal file
116
scripts/check-pr-template.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Validates that a pull request body follows .github/pull_request_template.md:
|
||||
* required sections are filled in and the key checklist items are ticked.
|
||||
*
|
||||
* Usage: bun scripts/check-pr-template.mjs <path-to-pr-body.txt>
|
||||
* Output: a JSON array of human-readable problems (empty array = all good).
|
||||
* Exit: 0 = ok, 1 = one or more problems, 2 = no body file given.
|
||||
*
|
||||
* Env: AUTHOR_ASSOCIATION — when OWNER/MEMBER/COLLABORATOR, the AI-disclosure
|
||||
* check is skipped (maintainers self-police).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
const bodyFile = process.argv[2];
|
||||
if (!bodyFile) {
|
||||
console.error("usage: bun scripts/check-pr-template.mjs <pr-body-file>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const body = readFileSync(bodyFile, "utf8").replace(/\r\n/g, "\n");
|
||||
const association = (process.env.AUTHOR_ASSOCIATION || "").toUpperCase();
|
||||
const isMaintainer = ["OWNER", "MEMBER", "COLLABORATOR"].includes(association);
|
||||
|
||||
// Strip HTML comments robustly: loop until stable so nested/overlapping `<!--`
|
||||
// markers can't survive a single pass, then drop any unterminated trailing
|
||||
// comment. (Also satisfies CodeQL's "incomplete multi-character sanitization".)
|
||||
const stripComments = (s) => {
|
||||
let out = s;
|
||||
let prev;
|
||||
do {
|
||||
prev = out;
|
||||
out = out.replace(/<!--[\s\S]*?-->/g, "");
|
||||
} while (out !== prev);
|
||||
return out.replace(/<!--[\s\S]*$/, "").trim();
|
||||
};
|
||||
|
||||
// Grab the text under a heading whose title contains `keyword`, up to the next heading
|
||||
// or the end of the body.
|
||||
const section = (keyword) => {
|
||||
const re = new RegExp(
|
||||
`(?:^|\\n)#{1,4}\\s*[^\\n]*${keyword}[^\\n]*\\n([\\s\\S]*?)(?=\\n#{1,4}\\s|$)`,
|
||||
"i",
|
||||
);
|
||||
const m = body.match(re);
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
|
||||
const isFilled = (content) => {
|
||||
if (content == null) return false;
|
||||
// Template guidance lives in HTML comments; once stripped, a real answer remains.
|
||||
return stripComments(content).length > 0;
|
||||
};
|
||||
|
||||
const issues = [];
|
||||
|
||||
if (section("Description") === null)
|
||||
issues.push("The **Description** section is missing.");
|
||||
else if (!isFilled(section("Description")))
|
||||
issues.push(
|
||||
"The **Description** section is empty — describe what changed and why.",
|
||||
);
|
||||
|
||||
if (section("Ticket") === null)
|
||||
issues.push("The **Ticket / Issue** section is missing.");
|
||||
else if (!isFilled(section("Ticket")))
|
||||
issues.push(
|
||||
"The **Ticket / Issue** section is empty — link an issue or write `N/A`.",
|
||||
);
|
||||
|
||||
if (section("Testing Instructions") === null)
|
||||
issues.push("The **Testing Instructions** section is missing.");
|
||||
else if (!isFilled(section("Testing Instructions")))
|
||||
issues.push(
|
||||
"The **Testing Instructions** section is empty — tell reviewers how to test this, or write `N/A`.",
|
||||
);
|
||||
|
||||
const checklist = section("Checklist");
|
||||
if (checklist === null) {
|
||||
issues.push("The **Checklist** section is missing.");
|
||||
} else {
|
||||
if (!/- \[x\][^\n]*contribution guidelines/i.test(checklist))
|
||||
issues.push(
|
||||
"Please read and tick the **contribution guidelines** checklist item.",
|
||||
);
|
||||
if (!isMaintainer && !/- \[x\][^\n]*declared if AI/i.test(checklist))
|
||||
issues.push(
|
||||
"Please tick the **AI disclosure** checklist item (declare whether AI was used).",
|
||||
);
|
||||
}
|
||||
|
||||
// Require the Screenshots section when the PR changes UI (.tsx under app/ or components/).
|
||||
// PR_FILES points to a newline list of changed paths (provided by the workflow).
|
||||
const filesPath = process.env.PR_FILES;
|
||||
if (filesPath && existsSync(filesPath)) {
|
||||
const changed = readFileSync(filesPath, "utf8").split("\n").filter(Boolean);
|
||||
const touchesUI = changed.some(
|
||||
(f) =>
|
||||
/^(app|components)\/.*\.tsx$/.test(f) && !/\.(test|spec)\.tsx$/.test(f),
|
||||
);
|
||||
if (touchesUI) {
|
||||
const shots = section("Screenshots");
|
||||
if (shots === null)
|
||||
issues.push(
|
||||
"This PR changes UI (`.tsx`) — add the **Screenshots / GIFs** section with before/after media.",
|
||||
);
|
||||
else if (!isFilled(shots))
|
||||
issues.push(
|
||||
"This PR changes UI — the **Screenshots / GIFs** section is empty; add screenshots (or write `N/A` if it's genuinely not visual).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(issues));
|
||||
process.exit(issues.length ? 1 : 0);
|
||||
Reference in New Issue
Block a user