From a119861771359f0136f6875f83c0bbe9b8f7f697 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:19:39 -0400 Subject: [PATCH 1/6] fix(ci): recognize namespaced test attributes in pr-triage's needs-tests check The needs-tests detection regex only matched a literal #[test] or #[cfg(test) immediately after #[, so it missed namespaced test attributes like #[tokio::test] or #[async_std::test]. Any PR whose only new tests were async (the common pattern in this codebase, e.g. crates/gl/src/agent.rs) got falsely labeled needs-tests even when tests were added in the same commit. Reproduced against PR #198: it added two #[tokio::test]-only tests in the same commit as the source change, and the triage bot still commented needs-tests. Widens the regex to also match any attribute ending in ::test], verified against representative patches including PR #198's actual diff. Closes #201 --- .github/workflows/pr-triage.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index 8d5a933d..37858ab7 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -70,9 +70,12 @@ 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. + // Async tests use a namespaced attribute (#[tokio::test], #[async_std::test], + // ...) rather than the bare #[test], so match any attribute ending in ::test] + // too, not just the exact #[test] / #[cfg(test) forms. const addsInlineTest = files.some(f => f.filename.endsWith(".rs") && f.patch && - /^\+.*#\[(test\]|cfg\(test\))/m.test(f.patch)); + /^\+.*(#\[test\]|#\[cfg\(test\)|::test\])/m.test(f.patch)); const touchedTests = names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest; if (changedRust && !touchedTests) want.add("needs-tests"); From 3927e657f94b6b46ae6d3cc1218b63315bf712c4 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:17:03 -0400 Subject: [PATCH 2/6] fix(ci): update pr-triage needs-tests regex to anchor attributes and cover parametrized tokio::test --- .github/workflows/pr-triage.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index 37858ab7..3ee00a0f 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -71,11 +71,13 @@ jobs: // 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. // Async tests use a namespaced attribute (#[tokio::test], #[async_std::test], - // ...) rather than the bare #[test], so match any attribute ending in ::test] - // too, not just the exact #[test] / #[cfg(test) forms. + // possibly with args like #[tokio::test(flavor = ...)]) rather than the bare + // #[test]. Anchor to a leading #[ so ::test in ordinary code (indexing, + // comments, strings) doesn't count, and end on \b so both ::test] and + // ::test(...) match. const addsInlineTest = files.some(f => f.filename.endsWith(".rs") && f.patch && - /^\+.*(#\[test\]|#\[cfg\(test\)|::test\])/m.test(f.patch)); + /^\+\s*#\[\s*(cfg\(\s*test|[\w-]+(::[\w-]+)*::test|test)\b/m.test(f.patch)); const touchedTests = names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest; if (changedRust && !touchedTests) want.add("needs-tests"); From 3cd26749245951fca4777f9103798205b54b74fa Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:06:52 -0400 Subject: [PATCH 3/6] fix(ci): robustly strip comments/strings and match parameterized tests --- .github/workflows/pr-triage.yml | 80 ++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index 3ee00a0f..f53fc9d9 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -72,12 +72,80 @@ jobs: // pattern) an inline #[test] / #[cfg(test)] block added inside the source file. // 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 #[ so ::test in ordinary code (indexing, - // comments, strings) doesn't count, and end on \b so both ::test] and - // ::test(...) match. - const addsInlineTest = files.some(f => - f.filename.endsWith(".rs") && f.patch && - /^\+\s*#\[\s*(cfg\(\s*test|[\w-]+(::[\w-]+)*::test|test)\b/m.test(f.patch)); + // #[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 = + /^[ \t]*#[ \t]*\[[ \t]*(cfg[ \t]*\([ \t]*test[ \t]*\)|[\w-]+([ \t]*::[ \t]*[\w-]+)*[ \t]*::[ \t]*test\b|test\b)/; + const addsInlineTest = files.some(f => { + if (!f.filename.endsWith(".rs") || !f.patch) return false; + const state = { blockComment: false, rawStringDelim: null, inNormalString: false }; + return f.patch.split("\n").some(line => { + if (line.startsWith("+++") || line.startsWith("---")) return false; + const isAdded = line.startsWith("+"); + if (!isAdded && !line.startsWith(" ")) return false; // skip removed lines/hunk headers + const content = line.slice(1); + + let stripped = ""; + let i = 0; + while (i < content.length) { + if (state.blockComment) { + if (content.slice(i, i + 2) === "*/") { + state.blockComment = false; + i += 2; + } else { + i += 1; + } + } else if (state.rawStringDelim) { + if (content.slice(i, i + state.rawStringDelim.length) === state.rawStringDelim) { + state.rawStringDelim = null; + i += state.rawStringDelim.length; + } 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; + } + if (content.slice(i, i + 2) === "/*") { + state.blockComment = true; + 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]; + i += 1; + } + } + + return isAdded && testAttrRe.test(stripped); + }); + }); const touchedTests = names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest; if (changedRust && !touchedTests) want.add("needs-tests"); From a781df554d0066b1063193df6c16130c6e57a4b7 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:57:01 -0400 Subject: [PATCH 4/6] fix(ci): lex the full head file so state survives across diff hunks A diff hunk carries no lexical context from lines outside it, so scanning patch text alone could misclassify an added line that sits inside a comment or raw string opened earlier in the file (or outside the hunk's context window). Fetch the head-version file content via the contents API and lex it in file order, checking only the lines the diff actually added. --- .github/workflows/pr-triage.yml | 173 ++++++++++++++++++++------------ 1 file changed, 110 insertions(+), 63 deletions(-) diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index f53fc9d9..41bb4b1c 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -78,74 +78,121 @@ jobs: // it as a test gate, and allow the whitespace Rust permits around :: and cfg(. const testAttrRe = /^[ \t]*#[ \t]*\[[ \t]*(cfg[ \t]*\([ \t]*test[ \t]*\)|[\w-]+([ \t]*::[ \t]*[\w-]+)*[ \t]*::[ \t]*test\b|test\b)/; - const addsInlineTest = files.some(f => { - if (!f.filename.endsWith(".rs") || !f.patch) return false; - const state = { blockComment: false, rawStringDelim: null, inNormalString: false }; - return f.patch.split("\n").some(line => { - if (line.startsWith("+++") || line.startsWith("---")) return false; - const isAdded = line.startsWith("+"); - if (!isAdded && !line.startsWith(" ")) return false; // skip removed lines/hunk headers - const content = line.slice(1); - - let stripped = ""; - let i = 0; - while (i < content.length) { - if (state.blockComment) { - if (content.slice(i, i + 2) === "*/") { - state.blockComment = false; - i += 2; - } else { - i += 1; - } - } else if (state.rawStringDelim) { - if (content.slice(i, i + state.rawStringDelim.length) === state.rawStringDelim) { - state.rawStringDelim = null; - i += state.rawStringDelim.length; - } else { - i += 1; - } - } else if (state.inNormalString) { - if (content[i] === "\\") { - i += 2; - } else if (content[i] === '"') { - state.inNormalString = false; - i += 1; - } else { - i += 1; - } + + // Strip comments and string contents from a line of Rust, carrying lexical + // state (open block comment / raw string / normal string) across calls so a + // token opened on an earlier line is honored on this one. + function stripRustLine(content, state) { + let stripped = ""; + let i = 0; + while (i < content.length) { + if (state.blockComment) { + if (content.slice(i, i + 2) === "*/") { + state.blockComment = false; + i += 2; } else { - if (content.slice(i, i + 2) === "//") { - break; - } - if (content.slice(i, i + 2) === "/*") { - state.blockComment = true; - 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]; i += 1; } + } else if (state.rawStringDelim) { + if (content.slice(i, i + state.rawStringDelim.length) === state.rawStringDelim) { + i += state.rawStringDelim.length; + state.rawStringDelim = null; + } 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; + } + if (content.slice(i, i + 2) === "/*") { + state.blockComment = true; + 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]; + i += 1; } + } + return stripped; + } - return isAdded && testAttrRe.test(stripped); - }); - }); + // 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; + for (const f of files) { + if (addsInlineTest) break; + if (!f.filename.endsWith(".rs") || !f.patch) continue; + const added = addedLineNumbers(f.patch); + if (added.size === 0) continue; + + 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) { + continue; // e.g. file since deleted; nothing to lex + } + + const state = { blockComment: false, rawStringDelim: null, inNormalString: false }; + const lines = fileContent.split("\n"); + for (let lineNo = 1; lineNo <= lines.length; lineNo++) { + const stripped = stripRustLine(lines[lineNo - 1], state); + if (added.has(lineNo) && testAttrRe.test(stripped)) { + addsInlineTest = true; + break; + } + } + } const touchedTests = names.some(n => n.includes("/tests/") || n.endsWith("_test.rs")) || addsInlineTest; if (changedRust && !touchedTests) want.add("needs-tests"); From 0f41f2578ef87505b4476712f4598deab24f98d9 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:12:59 -0400 Subject: [PATCH 5/6] fix(ci): handle nesting, multiline attributes, precise terminal matching, and bound Contents API scans --- .github/workflows/pr-triage.yml | 169 ++++++++++++++++++++------------ 1 file changed, 104 insertions(+), 65 deletions(-) diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index 41bb4b1c..5f9d0654 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -77,68 +77,84 @@ jobs: // 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 = - /^[ \t]*#[ \t]*\[[ \t]*(cfg[ \t]*\([ \t]*test[ \t]*\)|[\w-]+([ \t]*::[ \t]*[\w-]+)*[ \t]*::[ \t]*test\b|test\b)/; + /#[ \s]*\[[ \s]*(?:cfg[ \s]*\([ \s]*test[ \s]*\)|(?:[\w-]+(?:[ \s]*::[ \s]*[\w-]+)*[ \s]*::[ \s]*)?test\b[ \s]*[\]\()])/g; - // Strip comments and string contents from a line of Rust, carrying lexical - // state (open block comment / raw string / normal string) across calls so a - // token opened on an earlier line is honored on this one. - function stripRustLine(content, state) { + // 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 = ""; - let i = 0; - while (i < content.length) { - if (state.blockComment) { - if (content.slice(i, i + 2) === "*/") { - state.blockComment = false; - i += 2; - } else { - i += 1; - } - } else if (state.rawStringDelim) { - if (content.slice(i, i + state.rawStringDelim.length) === state.rawStringDelim) { - i += state.rawStringDelim.length; - state.rawStringDelim = null; - } else { - i += 1; - } - } else if (state.inNormalString) { - if (content[i] === "\\") { - i += 2; - } else if (content[i] === '"') { - state.inNormalString = false; - i += 1; + 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; } - } else { - if (content.slice(i, i + 2) === "//") { - break; - } - if (content.slice(i, i + 2) === "/*") { - state.blockComment = true; - 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]; - i += 1; + } + if (lineNo < lines.length) { + stripped += "\n"; + lineMap.push(lineNo); } } - return stripped; + return { stripped, lineMap }; } // New-file line numbers touched by "+" lines in a unified diff patch. @@ -167,11 +183,23 @@ jobs: // 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; - const added = addedLineNumbers(f.patch); - if (added.size === 0) 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 { @@ -180,17 +208,28 @@ jobs: }); fileContent = Buffer.from(res.data.content, "base64").toString("utf8"); } catch (e) { - continue; // e.g. file since deleted; nothing to lex + 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 state = { blockComment: false, rawStringDelim: null, inNormalString: false }; - const lines = fileContent.split("\n"); - for (let lineNo = 1; lineNo <= lines.length; lineNo++) { - const stripped = stripRustLine(lines[lineNo - 1], state); - if (added.has(lineNo) && testAttrRe.test(stripped)) { - addsInlineTest = true; - break; + 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 = From 2d8b7f7b36137d4a3795503da879da65ebd5ec3e Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:59:13 -0400 Subject: [PATCH 6/6] fix(ci): stop bounded scan from aborting triage, use blob API, tighten matching Addresses jatmn's second round of review on pr-triage's needs-tests check: hitting the per-PR fetch cap now logs and stops scanning instead of throwing (which was skipping label reconciliation entirely), file content comes from the Git blob API instead of the Contents API to avoid the encoding: none gap for 1-100MB files, the added-line check now requires a non-whitespace attribute character (not just added whitespace) on the added line, stripped literals leave a placeholder so they can't glue adjacent tokens into a false match, and the namespaced-test regex now accepts a leading :: for absolute-path attributes. --- .github/workflows/pr-triage.yml | 43 +++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index 5f9d0654..9ec5770a 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -75,13 +75,17 @@ jobs: // #[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(. + // it as a test gate, allow the whitespace Rust permits around :: and cfg(, and + // allow an optional leading :: for absolute-path attributes (::tokio::test). const testAttrRe = - /#[ \s]*\[[ \s]*(?:cfg[ \s]*\([ \s]*test[ \s]*\)|(?:[\w-]+(?:[ \s]*::[ \s]*[\w-]+)*[ \s]*::[ \s]*)?test\b[ \s]*[\]\()])/g; + /#[ \s]*\[[ \s]*(?:cfg[ \s]*\([ \s]*test[ \s]*\)|(?:[ \s]*::[ \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. + // original line mapping. Each skipped comment/string/char-literal leaves a single + // non-whitespace placeholder behind so tokens on either side of it can't glue + // together into a false attribute match (e.g. a macro arg list containing a + // stray "#" and a string literal must not read as "#[test]" once stripped). function stripRustFile(fileContent) { let stripped = ""; const lineMap = []; @@ -125,22 +129,30 @@ jobs: } if (content.slice(i, i + 2) === "/*") { state.blockCommentDepth = 1; + stripped += "\0"; + lineMap.push(lineNo); i += 2; continue; } const charMatch = content.slice(i).match(/^'(?:[^'\\]|\\.)'/); if (charMatch) { + stripped += "\0"; + lineMap.push(lineNo); i += charMatch[0].length; continue; } const rawMatch = content.slice(i).match(/^r(#*)"/); if (rawMatch) { state.rawStringDelim = '"' + rawMatch[1]; + stripped += "\0"; + lineMap.push(lineNo); i += rawMatch[0].length; continue; } if (content[i] === '"') { state.inNormalString = true; + stripped += "\0"; + lineMap.push(lineNo); i += 1; continue; } @@ -196,16 +208,28 @@ jobs: ); if (!patchHasPotentialTest) continue; + // Bound the API work, but don't abort the whole job when a large PR exceeds it: + // that would skip label reconciliation and the guidance comment for every + // managed label, not just needs-tests. Stop scanning further candidates and + // fall through with whatever inline-test evidence we've already found. if (contentFetchCount >= 15) { - throw new Error("PR is too large: exceeded maximum number of file content fetches (15) for inline test analysis."); + core.warning( + "More than 15 candidate Rust files touch tests/cfg; stopping inline-test analysis early." + ); + break; } contentFetchCount++; + // Use the Git Data API (blob sha from the diff) rather than the Contents API: + // the Contents API returns encoding "none" (no content) for files between 1-100MB, + // which the Git Data API supports directly. let fileContent; try { - const res = await github.rest.repos.getContent({ - owner, repo, path: f.filename, ref: pr.head.sha, - }); + const res = await github.rest.git.getBlob({ owner, repo, file_sha: f.sha }); + if (res.data.encoding !== "base64") { + core.warning(`Unsupported content encoding for ${f.filename}; skipping inline-test analysis for this file.`); + continue; + } fileContent = Buffer.from(res.data.content, "base64").toString("utf8"); } catch (e) { if (e.status === 404) { @@ -224,7 +248,10 @@ jobs: const start = match.index; const end = testAttrRe.lastIndex; for (let k = start; k < end; k++) { - if (added.has(lineMap[k])) { + // Require an actual attribute token (not just whitespace the match + // spans, e.g. a blank line inserted inside an otherwise unchanged + // multiline attribute) to be on an added line. + if (added.has(lineMap[k]) && !/\s/.test(stripped[k])) { addsInlineTest = true; break; }