Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/scripts/enforce-pr-target.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ describe("enforce-pr-target workflow", () => {
);
assert.ok(qualityCall, "must call collectPrQualityFailures");
assert.match(qualityCall[1], /stackedBase/);
assert.match(qualityCall[1], /changedFilePaths/);
assert.match(qualityCall[1], /filesTruncated/);
assert.match(workflow, /isChangedFileListTruncated/);
});

it("strips stale WRONG BRANCH prefix on failure when base is corrected", () => {
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/pr-quality-messages.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ function buildFailureSections(failures, { pr, allowedBases, defaultBase }) {
sections.push(
"⚠️ **UI screenshot required**",
"",
`This pull request mentions ${inlineCode("gui")} in its title or description, so it is treated as a GUI change.`,
`This pull request changes files under ${inlineCode("gui/")}, or GitHub returned an incomplete changed-file list for a large diff, so it is treated as a GUI change.`,
"",
`@${pr.user.login} Please add a screenshot of the UI change to the description — drag and drop the image into the description editor, or paste a markdown image such as ${inlineCode("![Screenshot](https://example.com/after.png)")}. The check re-runs automatically once the description is edited.`
);
Expand Down
63 changes: 50 additions & 13 deletions .github/scripts/pr-quality.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -168,16 +168,46 @@ function assessPrDescription(body) {
return { ok: false, reason: "thin" };
}

/**
* True when any changed path is the gui directory or inside it (slash-guarded).
* Mirrors `guiPathsChanged` in `scripts/doctor-gui-if-changed.ts`.
*/
function guiPathsChanged(files) {
return files.some(
(file) => file === "gui" || file.startsWith("gui/")
);
}

/**
* True when the changed-file list from `pulls.listFiles` cannot be trusted to
* be complete for screenshot gating. Missing or non-integer counts, a head
* mismatch between the count snapshot and the paginated list, or a count above
* the returned list length all fail closed.
*/
function isChangedFileListTruncated(changedFilesCount, listedLength, headMatches = true) {
if (!headMatches) return true;
if (!Number.isInteger(changedFilesCount) || changedFilesCount < 0) return true;
return changedFilesCount > listedLength;
}

/**
* True when the PR title or description names the GUI surface as a whole word.
* The description is template-stripped first so the template's own screenshot
* instruction cannot arm the gate on its own.
* instruction cannot arm the gate on its own. Negated phrases such as "no gui
* changes" are not treated as cues (see `segmentHasAffirmativeGuiCue`).
*/
function segmentHasAffirmativeGuiCue(text) {
if (typeof text !== "string" || !text.trim()) return false;
const segments = text.split(/(?<=[.!?\n])/);
return segments.some((segment) => {
if (!GUI_CUE_RE.test(segment)) return false;
const withoutNegated = segment.replace(GUI_OVERRIDE_RE, "");
return GUI_CUE_RE.test(withoutNegated);
});
}

function hasGuiCue(title, body) {
return (
(typeof title === "string" && GUI_CUE_RE.test(title)) ||
(typeof body === "string" && GUI_CUE_RE.test(body))
);
return segmentHasAffirmativeGuiCue(title) || segmentHasAffirmativeGuiCue(body);
}

/**
Expand Down Expand Up @@ -447,7 +477,14 @@ function collectPrQualityFailures({
/** True when baseRef is another open PR's head (stacked child). */
stackedBase = false,
/** Issue comments; a maintainer comment waives the GUI-screenshot gate. */
guiOverrideComments = []
guiOverrideComments = [],
/** Changed file paths from `pulls.listFiles` (repo-relative). */
changedFilePaths = [],
/**
* True when `pulls.listFiles` returned fewer paths than `pulls.get`
* `changed_files` (GitHub caps the file list at 3,000 entries).
*/
filesTruncated = false
}) {
const failures = [];
const wrongBase = !allowedBases.includes(baseRef) && !stackedBase;
Expand Down Expand Up @@ -475,14 +512,12 @@ function collectPrQualityFailures({
failures.push({ code: "bad_description", reason: desc.reason });
}

// GUI-cued PRs must prove the UI change visually. The template's own
// screenshot instruction is boilerplate, so it cannot trigger this gate. A
// maintainer comment saying the change does not touch the GUI waives it.
// PRs that change gui/ must prove the UI change visually. Text cues in the
// title or description are not enough — "no gui changes" in the body must
// not arm the gate when the diff is backend-only. A maintainer comment saying
// the change does not touch the GUI still waives a gui/ diff false positive.
if (
hasGuiCue(
title,
typeof body === "string" ? stripPrTemplateBoilerplate(body) : "",
) &&
(guiPathsChanged(changedFilePaths) || filesTruncated) &&
!hasScreenshotEvidence(body) &&
!hasGuiOverride({ comments: guiOverrideComments })
) {
Expand All @@ -500,6 +535,8 @@ module.exports = {
isWrongAncestry,
authorHasPushPermission,
assessPrDescription,
guiPathsChanged,
isChangedFileListTruncated,
hasGuiCue,
hasGuiOverride,
hasScreenshotEvidence,
Expand Down
114 changes: 111 additions & 3 deletions .github/scripts/pr-quality.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const {
authorHasPushPermission,
assessPrDescription,
hasGuiCue,
guiPathsChanged,
isChangedFileListTruncated,
hasGuiOverride,
hasScreenshotEvidence,
buildReviewReadinessSection,
Expand Down Expand Up @@ -137,6 +139,16 @@ describe("hasGuiCue", () => {
);
});

it("does not match negated gui phrases", () => {
assert.equal(hasGuiCue("", "no gui changes in this PR"), false);
assert.equal(hasGuiCue("", "Without gui changes"), false);
assert.equal(hasGuiCue("No GUI changes", ""), false);
assert.equal(
hasGuiCue("", "This does not change the API. Please add a gui screenshot."),
true,
);
});

it("does not match gui inside other words", () => {
assert.equal(hasGuiCue("Add contributor guidance", ""), false);
assert.equal(hasGuiCue("", "Fix the guild invitation bug"), false);
Expand All @@ -149,6 +161,28 @@ describe("hasGuiCue", () => {
});
});

describe("guiPathsChanged", () => {
it("matches gui/ paths with a slash guard", () => {
assert.equal(guiPathsChanged(["gui/src/App.tsx"]), true);
assert.equal(guiPathsChanged(["gui"]), true);
assert.equal(guiPathsChanged(["scripts/foo.ts", "gui/package.json"]), true);
assert.equal(guiPathsChanged(["scripts/foo.ts"]), false);
assert.equal(guiPathsChanged(["guitools/x.ts"]), false);
assert.equal(guiPathsChanged([]), false);
});
});

describe("isChangedFileListTruncated", () => {
it("treats head drift, invalid counts, and oversized lists as truncated", () => {
assert.equal(isChangedFileListTruncated(10, 10, false), true);
assert.equal(isChangedFileListTruncated(undefined, 10, true), true);
assert.equal(isChangedFileListTruncated(10.5, 10, true), true);
assert.equal(isChangedFileListTruncated(11, 10, true), true);
assert.equal(isChangedFileListTruncated(10, 10, true), false);
assert.equal(isChangedFileListTruncated(5, 10, true), false);
});
});

describe("hasGuiOverride", () => {
const owner = { author_association: "OWNER", body: "Not touching gui here." };
const collaborator = { author_association: "COLLABORATOR", body: "no gui changes needed" };
Expand Down Expand Up @@ -792,7 +826,21 @@ describe("collectPrQualityFailures", () => {
assert.ok(failures.some((f) => f.code === "wrong_base"));
});

it("flags a gui title without a screenshot", () => {
it("flags gui/ file changes without a screenshot", () => {
const failures = collectPrQualityFailures({
baseRef: "dev",
allowedBases: allowed,
title: "Fix dashboard spacing",
body: richBody,
behindMain: 0,
behindBase: 0,
authorPermission: "read",
changedFilePaths: ["gui/src/App.tsx"],
});
assert.ok(failures.some((f) => f.code === "missing_ui_screenshot"));
});

it("does not flag a gui title when no gui/ files changed", () => {
const failures = collectPrQualityFailures({
baseRef: "dev",
allowedBases: allowed,
Expand All @@ -801,11 +849,65 @@ describe("collectPrQualityFailures", () => {
behindMain: 0,
behindBase: 0,
authorPermission: "read",
changedFilePaths: ["scripts/foo.ts"],
});
assert.ok(!failures.some((f) => f.code === "missing_ui_screenshot"));
});

it("flags truncated file lists even when gui/ is not in the partial list", () => {
const truncatedPaths = Array.from({ length: 3000 }, (_, index) => `scripts/file-${index}.ts`);
const failures = collectPrQualityFailures({
baseRef: "dev",
allowedBases: allowed,
title: "Large refactor",
body: richBody,
behindMain: 0,
behindBase: 0,
authorPermission: "read",
changedFilePaths: truncatedPaths,
filesTruncated: true,
});
assert.ok(failures.some((f) => f.code === "missing_ui_screenshot"));
});

it("flags a gui mention in the body without a screenshot", () => {
it("flags truncated file lists when gui/ appears in the partial list", () => {
const truncatedPaths = Array.from({ length: 2999 }, (_, index) => `scripts/file-${index}.ts`);
truncatedPaths.push("gui/src/App.tsx");
const failures = collectPrQualityFailures({
baseRef: "dev",
allowedBases: allowed,
title: "Large refactor with gui tweak",
body: richBody,
behindMain: 0,
behindBase: 0,
authorPermission: "read",
changedFilePaths: truncatedPaths,
filesTruncated: true,
});
assert.ok(failures.some((f) => f.code === "missing_ui_screenshot"));
});

it("does not flag no gui changes text without gui/ file changes", () => {
const failures = collectPrQualityFailures({
baseRef: "dev",
allowedBases: allowed,
title: "Fix proxy routing",
body: [
"## Summary",
"No gui changes in this PR; proxy routing only.",
"",
"## Test plan",
"- Ran bun test tests/ci-workflows.test.ts",
].join("\n"),
behindMain: 0,
behindBase: 0,
authorPermission: "read",
changedFilePaths: ["scripts/foo.ts"],
});
assert.ok(!failures.some((f) => f.code === "missing_ui_screenshot"));
});

it("flags a gui mention in the body without a screenshot when gui/ changed", () => {
const failures = collectPrQualityFailures({
baseRef: "dev",
allowedBases: allowed,
Expand All @@ -820,6 +922,7 @@ describe("collectPrQualityFailures", () => {
behindMain: 0,
behindBase: 0,
authorPermission: "read",
changedFilePaths: ["gui/src/styles.css"],
});
assert.ok(failures.some((f) => f.code === "missing_ui_screenshot"));
});
Expand All @@ -839,6 +942,7 @@ describe("collectPrQualityFailures", () => {
behindMain: 0,
behindBase: 0,
authorPermission: "read",
changedFilePaths: ["gui/src/App.tsx"],
guiOverrideComments: [
{ author_association: "OWNER", body: "no gui changes here" },
],
Expand All @@ -861,6 +965,7 @@ describe("collectPrQualityFailures", () => {
behindMain: 0,
behindBase: 0,
authorPermission: "read",
changedFilePaths: ["gui/src/App.tsx"],
guiOverrideComments: [
{ author_association: "CONTRIBUTOR", body: "no gui changes here" },
],
Expand All @@ -885,6 +990,7 @@ describe("collectPrQualityFailures", () => {
behindMain: 0,
behindBase: 0,
authorPermission: "read",
changedFilePaths: ["gui/src/App.tsx"],
});
assert.ok(!failures.some((f) => f.code === "missing_ui_screenshot"));
});
Expand All @@ -908,11 +1014,12 @@ describe("collectPrQualityFailures", () => {
behindMain: 0,
behindBase: 0,
authorPermission: "read",
changedFilePaths: ["gui/src/App.tsx"],
});
assert.ok(!failures.some((f) => f.code === "missing_ui_screenshot"));
});

it("still flags a gui title when image syntax is only inside a code fence", () => {
it("still flags gui/ changes when image syntax is only inside a code fence", () => {
const failures = collectPrQualityFailures({
baseRef: "dev",
allowedBases: allowed,
Expand All @@ -931,6 +1038,7 @@ describe("collectPrQualityFailures", () => {
behindMain: 0,
behindBase: 0,
authorPermission: "read",
changedFilePaths: ["gui/src/App.tsx"],
});
assert.ok(failures.some((f) => f.code === "missing_ui_screenshot"));
});
Expand Down
54 changes: 49 additions & 5 deletions .github/workflows/enforce-pr-target.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ jobs:
collectPrQualityFailures,
authorHasPushPermission,
hasGuiOverride,
isChangedFileListTruncated,
extractReviewReadiness,
appendReviewReadinessSection,
stripReviewReadinessSection,
Expand Down Expand Up @@ -534,6 +535,51 @@ jobs:
}
}

const changedFiles = [];
const changedFilePaths = [];
let filesTruncated = true;
for (let attempt = 0; attempt < 2; attempt += 1) {
const { data: fileSnapshot } = await github.rest.pulls.get({
owner,
repo,
pull_number
});
const headShaForFiles = fileSnapshot.head?.sha ?? "";
const listedFiles = await github.paginate(
github.rest.pulls.listFiles,
{ owner, repo, pull_number, per_page: 100 }
);
const { data: fileVerify } = await github.rest.pulls.get({
owner,
repo,
pull_number
});
const headMatches = fileVerify.head?.sha === headShaForFiles;
if (!headMatches && attempt === 0) {
core.info(
"PR head moved while listing changed files; retrying once."
);
continue;
}
if (!headMatches) {
core.warning(
"PR head moved during changed-file snapshot; treating file list as truncated."
);
}
changedFiles.length = 0;
changedFiles.push(...listedFiles);
changedFilePaths.length = 0;
changedFilePaths.push(
...listedFiles.map(file => file.filename).filter(Boolean)
);
filesTruncated = isChangedFileListTruncated(
fileSnapshot.changed_files,
listedFiles.length,
headMatches
);
break;
}

let failures = collectPrQualityFailures({
baseRef: pr.base.ref,
allowedBases: ALLOWED_BASES,
Expand All @@ -548,17 +594,15 @@ jobs:
stackedBase,
// A maintainer issue comment ("not touching gui") waives the
// GUI-screenshot gate; the comments are already fetched above.
guiOverrideComments: comments
guiOverrideComments: comments,
changedFilePaths,
filesTruncated
});

// Hygiene is a separate workflow that owns the blocked label and
// the Hygiene comment section, but Ready / review-ready must not
// clear while those checks fail. Re-assess here from the same
// trusted scripts so the gate cannot race ahead of hygiene.
const changedFiles = await github.paginate(
github.rest.pulls.listFiles,
{ owner, repo, pull_number, per_page: 100 }
);
const labelNames = (pr.labels ?? []).map(label => label.name);
failures = [
...failures,
Expand Down
Loading
Loading