Skip to content
Open
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
165 changes: 162 additions & 3 deletions .github/workflows/pr-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,168 @@ jobs:
const changedRust = names.some(n => n.startsWith("crates/") && n.endsWith(".rs"));
// A test can live in a tests/ dir, a *_test.rs file, or (the common Rust
// pattern) an inline #[test] / #[cfg(test)] block added inside the source file.
const addsInlineTest = files.some(f =>
f.filename.endsWith(".rs") && f.patch &&
/^\+.*#\[(test\]|cfg\(test\))/m.test(f.patch));
// Async tests use a namespaced attribute (#[tokio::test], #[async_std::test],
// possibly with args like #[tokio::test(flavor = ...)]) rather than the bare
// #[test]. Anchor to a leading #[ (horizontal whitespace only, so the attribute
// itself must be on the added line) so ::test in ordinary code (indexing,
// comments, strings) doesn't count, require cfg(test) to close before accepting
// it as a test gate, and allow the whitespace Rust permits around :: and cfg(.
const testAttrRe =
/#[ \s]*\[[ \s]*(?:cfg[ \s]*\([ \s]*test[ \s]*\)|(?:[\w-]+(?:[ \s]*::[ \s]*[\w-]+)*[ \s]*::[ \s]*)?test\b[ \s]*[\]\()])/g;

// Strip comments and string contents from a file of Rust, carrying lexical
// state (open block comment / raw string / normal string) and tracking
// original line mapping.
function stripRustFile(fileContent) {
let stripped = "";
const lineMap = [];
const state = { blockCommentDepth: 0, rawStringDelim: null, inNormalString: false };
const lines = fileContent.split("\n");

for (let lineNo = 1; lineNo <= lines.length; lineNo++) {
const content = lines[lineNo - 1];
let i = 0;
while (i < content.length) {
if (state.blockCommentDepth > 0) {
if (content.slice(i, i + 2) === "*/") {
state.blockCommentDepth--;
i += 2;
} else if (content.slice(i, i + 2) === "/*") {
state.blockCommentDepth++;
i += 2;
} else {
i += 1;
}
} else if (state.rawStringDelim) {
const len = state.rawStringDelim.length;
if (content.slice(i, i + len) === state.rawStringDelim) {
state.rawStringDelim = null;
i += len;
} else {
i += 1;
}
} else if (state.inNormalString) {
if (content[i] === "\\") {
i += 2;
} else if (content[i] === '"') {
state.inNormalString = false;
i += 1;
} else {
i += 1;
}
} else {
if (content.slice(i, i + 2) === "//") {
break; // skip rest of line
}
if (content.slice(i, i + 2) === "/*") {
state.blockCommentDepth = 1;
i += 2;
continue;
}
const charMatch = content.slice(i).match(/^'(?:[^'\\]|\\.)'/);
if (charMatch) {
i += charMatch[0].length;
continue;
}
const rawMatch = content.slice(i).match(/^r(#*)"/);
if (rawMatch) {
state.rawStringDelim = '"' + rawMatch[1];
i += rawMatch[0].length;
continue;
}
if (content[i] === '"') {
state.inNormalString = true;
i += 1;
continue;
}
stripped += content[i];
lineMap.push(lineNo);
i += 1;
}
}
if (lineNo < lines.length) {
stripped += "\n";
lineMap.push(lineNo);
}
}
return { stripped, lineMap };
}

// New-file line numbers touched by "+" lines in a unified diff patch.
function addedLineNumbers(patch) {
const added = new Set();
let newLine = null;
for (const line of patch.split("\n")) {
const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (hunk) {
newLine = parseInt(hunk[1], 10);
continue;
}
if (newLine === null) continue;
if (line.startsWith("+") && !line.startsWith("+++")) {
added.add(newLine);
newLine += 1;
} else if (!line.startsWith("-") || line.startsWith("---")) {
newLine += 1; // context line
}
}
return added;
}

// A diff hunk carries no lexical context from lines outside it, so scanning
// patch text alone can misclassify an added line that sits inside a comment or
// raw string opened earlier in the file. Lex the complete head-version file
// instead, in file order, and only test the lines the diff actually added.
let addsInlineTest = false;
let contentFetchCount = 0;
for (const f of files) {
if (addsInlineTest) break;
if (!f.filename.endsWith(".rs") || !f.patch) continue;

// Pre-filter: only fetch and parse file contents if the patch actually adds
// something that looks like a potential test attribute or cfg gate.
const patchHasPotentialTest = f.patch.split("\n").some(line =>
line.startsWith("+") && !line.startsWith("+++") &&
(line.toLowerCase().includes("test") || line.toLowerCase().includes("cfg"))
);
if (!patchHasPotentialTest) continue;

if (contentFetchCount >= 15) {
throw new Error("PR is too large: exceeded maximum number of file content fetches (15) for inline test analysis.");
}
contentFetchCount++;

let fileContent;
try {
const res = await github.rest.repos.getContent({
owner, repo, path: f.filename, ref: pr.head.sha,
});
fileContent = Buffer.from(res.data.content, "base64").toString("utf8");
} catch (e) {
if (e.status === 404) {
continue; // file since deleted; nothing to lex
}
throw new Error(`Failed to fetch content for ${f.filename}: ${e.message || e}`);
}

const added = addedLineNumbers(f.patch);
if (added.size === 0) continue;

const { stripped, lineMap } = stripRustFile(fileContent);
let match;
testAttrRe.lastIndex = 0;
while ((match = testAttrRe.exec(stripped)) !== null) {
const start = match.index;
const end = testAttrRe.lastIndex;
for (let k = start; k < end; k++) {
if (added.has(lineMap[k])) {
addsInlineTest = true;
break;
}
}
if (addsInlineTest) break;
}
}
const touchedTests =
names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest;
if (changedRust && !touchedTests) want.add("needs-tests");
Expand Down
Loading