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
32 changes: 23 additions & 9 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ jobs:
const pulls = [];
const paths = [];
let truncated = false;
let relinker = manual;
for (const pull_number of numbers) {
let { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
for (let attempt = 0; pr.mergeable === null && attempt < 10; ++attempt) {
Expand All @@ -160,19 +159,34 @@ jobs:
if (pr.mergeable === false) {
throw new Error(`Pull request #${pull_number} does not merge cleanly into main; rebase it first`);
}
if (!manual) {
const { data: commits } = await github.rest.repos.listCommits({
owner, repo, sha: 'main', author: pr.user.login, per_page: 1
});
relinker = commits.length > 0;
}
const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number, per_page: 100 });
truncated ||= files.length >= 3000;
paths.push(...files.flatMap((file) => [file.filename, file.previous_filename].filter(Boolean)));
pulls.push({ number: pull_number, head: pr.head.sha });
}
const run = select(paths, truncated);
relinker = relinker && run.relinker;
let relinker = run.relinker;
if (relinker && !manual) {
const author = context.payload.pull_request.user.login;
const { data: commits } = await github.rest.repos.listCommits({
owner, repo, sha: 'main', author, per_page: 1
});
relinker = commits.length > 0;
if (!relinker) {
try {
const { data: mergedPRs } = await github.rest.search.issuesAndPullRequests({
q: `repo:${owner}/${repo} is:pr is:merged base:main author:${author}`,
per_page: 1
});
if (mergedPRs.incomplete_results) {
throw new Error('the search results are incomplete');
}
relinker = mergedPRs.total_count > 0;
} catch (error) {
core.warning(`Skipping the relinker: could not search the merged pull requests of ${author}: ${error.message}`);
}
}
}
const registerContext = run.registerContext;
const writeWatch = run.writeWatch;
const { data: main } = await github.rest.repos.getBranch({ owner, repo, branch: 'main' });
Expand All @@ -188,7 +202,7 @@ jobs:
merged.forEach((line) => core.info(line));
await core.summary.addRaw(
`${merged.join(', then ')}: full builds ${full ? 'enabled' : 'disabled'}, ` +
`relinker ${relinker ? (full ? (linux ? 'enabled on macOS (the full builds run the Linux and Windows relinker tests)' : 'enabled on Linux and macOS (the full Windows build runs the Windows relinker tests)') : 'enabled') : 'skipped: no relinker file changed or the author has no commit in main'}, ` +
`relinker ${relinker ? (full ? (linux ? 'enabled on macOS (the full builds run the Linux and Windows relinker tests)' : 'enabled on Linux and macOS (the full Windows build runs the Windows relinker tests)') : 'enabled') : 'skipped: no relinker file changed, or no commit or merged pull request in main was found for the author'}, ` +
`register context tests ${registerContext ? 'enabled' : 'skipped'}, write watch tests ${writeWatch ? 'enabled' : 'skipped'}`
).write();

Expand Down
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ if(BUILD_TESTING)
${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_check_conventions_doc_links.py)
add_test(NAME test_registration COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_test_registration.py)
add_test(NAME build_eligibility COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_build_eligibility.py)
add_test(NAME check_skips COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_check_skips.py)
add_test(NAME check_main_sync COMMAND ${Python3_EXECUTABLE}
Expand Down
146 changes: 146 additions & 0 deletions tools/tests/test_build_eligibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import json
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path

WORKFLOW = Path(__file__).resolve().parents[2] / ".github/workflows/build.yml"

HARNESS = r"""
const fs = require('fs');
const scenario = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const script = fs.readFileSync(process.argv[3], 'utf8');
const calls = [];
const outputs = {};
const warnings = [];
let summary = '';
const github = {
rest: {
pulls: {
get: async () => ({ data: { state: 'open', base: { ref: 'main' }, head: { sha: 'head' }, mergeable: true, user: { login: 'author' } } }),
listFiles: async () => ({ data: [] })
},
repos: {
listCommits: async (params) => {
calls.push(['listCommits', params.sha, params.author]);
return { data: scenario.commits };
},
getBranch: async () => ({ data: { commit: { sha: 'main' } } }),
getCollaboratorPermissionLevel: async () => ({ data: { permission: 'write' } })
},
search: {
issuesAndPullRequests: async (params) => {
calls.push(['search', params.q]);
if (scenario.search === null) {
throw Object.assign(new Error('API rate limit exceeded'), { status: 403 });
}
return { data: scenario.search };
}
}
},
paginate: async () => scenario.files.map((filename) => ({ filename }))
};
const context = {
eventName: scenario.manual ? 'workflow_dispatch' : 'pull_request',
actor: 'maintainer',
repo: { owner: 'owner', repo: 'project' },
payload: { pull_request: { number: 7, head: { sha: 'head' }, user: { login: 'author' } } }
};
const core = {
setOutput: (name, value) => { outputs[name] = value; },
info: () => {},
warning: (message) => { warnings.push(String(message)); },
summary: { addRaw: (text) => { summary += text; return { write: async () => {} }; } }
};
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
new AsyncFunction('github', 'context', 'core', script)(github, context, core).then(
() => process.stdout.write(JSON.stringify({ outputs, warnings, calls, summary })),
(error) => { console.error(error); process.exit(1); }
);
"""


def gate_script():
lines = WORKFLOW.read_text(encoding="utf-8").splitlines()
start = next(index for index, line in enumerate(lines) if line.strip() == "id: gate")
start = next(index for index in range(start, len(lines)) if lines[index].strip() == "script: |") + 1
indent = len(lines[start]) - len(lines[start].lstrip())
script = []
for line in lines[start:]:
if line.strip() and len(line) - len(line.lstrip()) < indent:
break
script.append(line[indent:])
return "\n".join(script)


class RelinkerEligibilityTests(unittest.TestCase):
def setUp(self):
self.node = shutil.which("node")
if not self.node:
self.skipTest("the eligibility script runs in node")
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
(self.root / "harness.js").write_text(HARNESS, encoding="utf-8")
(self.root / "gate.js").write_text(gate_script(), encoding="utf-8")

def tearDown(self):
self.temp.cleanup()

def run_gate(self, commits, search, files=("core/relinker/main.cpp",), manual=False):
scenario = self.root / "scenario.json"
scenario.write_text(json.dumps({"commits": commits, "search": search, "files": list(files), "manual": manual}),
encoding="utf-8")
environment = dict(os.environ, PR_NUMBER="7", GITHUB_TRIGGERING_ACTOR="maintainer")
result = subprocess.run([self.node, "harness.js", "scenario.json", "gate.js"], cwd=self.root,
capture_output=True, text=True, env=environment, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return json.loads(result.stdout)

def test_author_with_a_commit_in_main_runs_the_relinker(self):
result = self.run_gate([{"sha": "a"}], None)
self.assertIs(result["outputs"]["relinker"], True)
self.assertEqual(result["calls"], [["listCommits", "main", "author"]])

def test_author_with_only_merged_pull_requests_runs_the_relinker(self):
result = self.run_gate([], {"total_count": 12, "incomplete_results": False, "items": []})
self.assertIs(result["outputs"]["relinker"], True)
self.assertEqual(result["calls"], [["listCommits", "main", "author"],
["search", "repo:owner/project is:pr is:merged base:main author:author"]])
self.assertEqual(result["warnings"], [])

def test_author_without_merged_pull_requests_skips_the_relinker(self):
result = self.run_gate([], {"total_count": 0, "incomplete_results": False, "items": []})
self.assertIs(result["outputs"]["relinker"], False)
self.assertEqual(result["warnings"], [])
self.assertIn("no commit or merged pull request in main was found for the author", result["summary"])

def test_failed_search_skips_the_relinker_with_a_warning(self):
result = self.run_gate([], None)
self.assertIs(result["outputs"]["relinker"], False)
self.assertEqual(result["outputs"]["sha"], "main")
self.assertEqual(len(result["warnings"]), 1)
self.assertIn("API rate limit exceeded", result["warnings"][0])

def test_incomplete_search_skips_the_relinker_with_a_warning(self):
result = self.run_gate([], {"total_count": 3, "incomplete_results": True, "items": []})
self.assertIs(result["outputs"]["relinker"], False)
self.assertEqual(len(result["warnings"]), 1)
self.assertIn("incomplete", result["warnings"][0])

def test_pull_request_without_relinker_files_checks_no_author(self):
result = self.run_gate([], None, files=("core/libs/tests/write_watch/a.cpp",))
self.assertIs(result["outputs"]["relinker"], False)
self.assertIs(result["outputs"]["write-watch"], True)
self.assertEqual(result["calls"], [])

def test_manual_run_checks_no_author(self):
result = self.run_gate([], None, manual=True)
self.assertIs(result["outputs"]["relinker"], True)
self.assertEqual(result["calls"], [])
self.assertIs(self.run_gate([], None, files=("README.md",), manual=True)["outputs"]["relinker"], False)


if __name__ == "__main__":
unittest.main()
Loading