| import datetime |
| import json |
| import os |
| from email.message import EmailMessage |
| from typing import Any, Dict, List, Optional |
| from unittest import mock |
| |
| import pytest |
| |
| from b4.review import checks |
| |
| # --------------------------------------------------------------------------- |
| # Helpers |
| # --------------------------------------------------------------------------- |
| |
| |
| def _make_msg( |
| subject: str = 'test patch', msgid: str = 'abc@example.com', body: str = 'dummy' |
| ) -> EmailMessage: |
| """Create a minimal EmailMessage for testing.""" |
| msg = EmailMessage() |
| msg['Subject'] = subject |
| msg['Message-Id'] = f'<{msgid}>' |
| msg.set_content(body) |
| return msg |
| |
| |
| # --------------------------------------------------------------------------- |
| # SQLite cache: store / retrieve / delete / cleanup |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestCacheDb: |
| """Tests for the CI check cache database.""" |
| |
| def test_get_db_creates_schema(self, tmp_path: pytest.TempPathFactory) -> None: |
| conn = checks.get_db() |
| cursor = conn.execute( |
| "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" |
| ) |
| tables = [row[0] for row in cursor.fetchall()] |
| assert 'check_results' in tables |
| assert 'schema_version' in tables |
| conn.close() |
| |
| def test_store_and_retrieve(self, tmp_path: pytest.TempPathFactory) -> None: |
| conn = checks.get_db() |
| results = [ |
| { |
| 'tool': 'lint', |
| 'status': 'pass', |
| 'summary': 'ok', |
| 'url': '', |
| 'details': '', |
| }, |
| { |
| 'tool': 'build', |
| 'status': 'fail', |
| 'summary': 'broken', |
| 'url': 'https://ci.example.com', |
| 'details': 'error on line 5', |
| }, |
| ] |
| checks.store_results(conn, 'msg1@example', results) |
| cached = checks.get_cached_results(conn, ['msg1@example']) |
| assert 'msg1@example' in cached |
| assert len(cached['msg1@example']) == 2 |
| tools = {r['tool'] for r in cached['msg1@example']} |
| assert tools == {'lint', 'build'} |
| conn.close() |
| |
| def test_retrieve_empty(self, tmp_path: pytest.TempPathFactory) -> None: |
| conn = checks.get_db() |
| cached = checks.get_cached_results(conn, ['nonexistent@example']) |
| assert cached == {} |
| conn.close() |
| |
| def test_retrieve_empty_list(self, tmp_path: pytest.TempPathFactory) -> None: |
| conn = checks.get_db() |
| cached = checks.get_cached_results(conn, []) |
| assert cached == {} |
| conn.close() |
| |
| def test_store_replaces_existing(self, tmp_path: pytest.TempPathFactory) -> None: |
| conn = checks.get_db() |
| checks.store_results( |
| conn, 'msg@ex', [{'tool': 'lint', 'status': 'pass', 'summary': 'v1'}] |
| ) |
| checks.store_results( |
| conn, 'msg@ex', [{'tool': 'lint', 'status': 'fail', 'summary': 'v2'}] |
| ) |
| cached = checks.get_cached_results(conn, ['msg@ex']) |
| assert cached['msg@ex'][0]['status'] == 'fail' |
| assert cached['msg@ex'][0]['summary'] == 'v2' |
| conn.close() |
| |
| def test_delete_results(self, tmp_path: pytest.TempPathFactory) -> None: |
| conn = checks.get_db() |
| checks.store_results(conn, 'a@ex', [{'tool': 't1', 'status': 'pass'}]) |
| checks.store_results(conn, 'b@ex', [{'tool': 't1', 'status': 'pass'}]) |
| checks.delete_results(conn, ['a@ex']) |
| cached = checks.get_cached_results(conn, ['a@ex', 'b@ex']) |
| assert 'a@ex' not in cached |
| assert 'b@ex' in cached |
| conn.close() |
| |
| def test_delete_empty_list(self, tmp_path: pytest.TempPathFactory) -> None: |
| conn = checks.get_db() |
| checks.delete_results(conn, []) # should not raise |
| conn.close() |
| |
| def test_cleanup_old(self, tmp_path: pytest.TempPathFactory) -> None: |
| conn = checks.get_db() |
| checks.store_results(conn, 'recent@ex', [{'tool': 't', 'status': 'pass'}]) |
| # Manually backdate one row |
| old_date = ( |
| datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=200) |
| ).isoformat() |
| conn.execute( |
| 'INSERT OR REPLACE INTO check_results' |
| ' (msgid, tool, status, checked_at)' |
| ' VALUES (?, ?, ?, ?)', |
| ('old@ex', 't', 'pass', old_date), |
| ) |
| conn.commit() |
| deleted = checks.cleanup_old(conn, max_days=180) |
| assert deleted == 1 |
| cached = checks.get_cached_results(conn, ['recent@ex', 'old@ex']) |
| assert 'recent@ex' in cached |
| assert 'old@ex' not in cached |
| conn.close() |
| |
| |
| # --------------------------------------------------------------------------- |
| # parse_cmd |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestParseCmd: |
| """Tests for parse_cmd shell splitting.""" |
| |
| def test_simple(self) -> None: |
| assert checks.parse_cmd('/usr/bin/check') == ['/usr/bin/check'] |
| |
| def test_with_args(self) -> None: |
| assert checks.parse_cmd('check --verbose -q') == ['check', '--verbose', '-q'] |
| |
| def test_quoted_arg(self) -> None: |
| assert checks.parse_cmd('check "hello world"') == ['check', 'hello world'] |
| |
| def test_single_quotes(self) -> None: |
| assert checks.parse_cmd("check 'hello world'") == ['check', 'hello world'] |
| |
| |
| # --------------------------------------------------------------------------- |
| # _run_builtin_checkpatch output parsing |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestBuiltinCheckpatch: |
| """Tests for _run_builtin_checkpatch output parsing.""" |
| |
| def _run( |
| self, stdout: str, stderr: str = '', ecode: int = 0, topdir: str = '/fake' |
| ) -> List[Dict[str, str]]: |
| msg = _make_msg() |
| with ( |
| mock.patch('os.access', return_value=True), |
| mock.patch( |
| 'b4._run_command', |
| return_value=( |
| ecode, |
| stdout.encode() if stdout else b'', |
| stderr.encode() if stderr else b'', |
| ), |
| ), |
| mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''), |
| ): |
| return checks._run_builtin_checkpatch(msg, topdir) |
| |
| def test_clean_pass(self) -> None: |
| results = self._run('', ecode=0) |
| assert len(results) == 1 |
| assert results[0]['status'] == 'pass' |
| assert results[0]['tool'] == 'checkpatch' |
| |
| def test_error_lines(self) -> None: |
| results = self._run('ERROR: trailing whitespace\n') |
| assert results[0]['status'] == 'fail' |
| assert '1 error' in results[0]['summary'] |
| |
| def test_warning_lines(self) -> None: |
| results = self._run('WARNING: missing Signed-off-by\n') |
| assert results[0]['status'] == 'warn' |
| assert '1 warning' in results[0]['summary'] |
| |
| def test_check_treated_as_warning(self) -> None: |
| results = self._run('CHECK: braces not needed\n') |
| assert results[0]['status'] == 'warn' |
| |
| def test_mixed_errors_and_warnings(self) -> None: |
| output = 'ERROR: bad thing\nWARNING: mild thing\nWARNING: another\n' |
| results = self._run(output) |
| assert results[0]['status'] == 'fail' |
| assert '1 error' in results[0]['summary'] |
| assert '2 warnings' in results[0]['summary'] |
| |
| def test_continuation_lines(self) -> None: |
| output = 'WARNING: first part\n continuation of warning\n' |
| results = self._run(output) |
| findings = json.loads(results[0]['details']) |
| assert len(findings) == 1 |
| assert 'continuation' in findings[0]['description'] |
| |
| def test_nonzero_exit_no_output(self) -> None: |
| results = self._run('', ecode=1) |
| assert results[0]['status'] == 'fail' |
| assert 'error code' in results[0]['summary'] |
| |
| def test_not_executable(self) -> None: |
| msg = _make_msg() |
| with mock.patch('os.access', return_value=False): |
| results = checks._run_builtin_checkpatch(msg, '/fake') |
| assert results[0]['status'] == 'fail' |
| assert 'not found' in results[0]['summary'] |
| |
| def test_dash_prefix_stripped(self) -> None: |
| results = self._run('-:42: WARNING: something bad\n') |
| findings = json.loads(results[0]['details']) |
| # The leading "-:" should be stripped |
| assert not findings[0]['description'].startswith('-:') |
| |
| |
| # --------------------------------------------------------------------------- |
| # _run_external_cmd JSON protocol |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestRunExternalCmd: |
| """Tests for _run_external_cmd JSON parsing.""" |
| |
| def _run( |
| self, stdout: str, stderr: str = '', ecode: int = 0 |
| ) -> List[Dict[str, str]]: |
| msg = _make_msg() |
| with ( |
| mock.patch( |
| 'b4._run_command', |
| return_value=( |
| ecode, |
| stdout.encode() if stdout else b'', |
| stderr.encode() if stderr else b'', |
| ), |
| ), |
| mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''), |
| ): |
| return checks._run_external_cmd(['mycheck'], msg, '/fake') |
| |
| def test_valid_json_array(self) -> None: |
| data = [{'tool': 'ci', 'status': 'pass', 'summary': 'ok'}] |
| results = self._run(json.dumps(data)) |
| assert len(results) == 1 |
| assert results[0]['tool'] == 'ci' |
| assert results[0]['status'] == 'pass' |
| |
| def test_single_object_wrapped(self) -> None: |
| data = {'tool': 'ci', 'status': 'warn', 'summary': 'hmm'} |
| results = self._run(json.dumps(data)) |
| assert len(results) == 1 |
| assert results[0]['status'] == 'warn' |
| |
| def test_invalid_json(self) -> None: |
| results = self._run('not json at all') |
| assert len(results) == 1 |
| assert results[0]['status'] == 'fail' |
| assert 'invalid JSON' in results[0]['summary'] |
| |
| def test_empty_output_zero_exit(self) -> None: |
| results = self._run('') |
| assert results == [] |
| |
| def test_empty_output_nonzero_exit(self) -> None: |
| results = self._run('', ecode=1) |
| assert len(results) == 1 |
| assert results[0]['status'] == 'fail' |
| assert 'error code' in results[0]['summary'] |
| |
| def test_invalid_status_defaults_to_fail(self) -> None: |
| data = [{'tool': 'ci', 'status': 'banana'}] |
| results = self._run(json.dumps(data)) |
| assert results[0]['status'] == 'fail' |
| |
| def test_missing_tool_uses_basename(self) -> None: |
| data = [{'status': 'pass'}] |
| results = self._run(json.dumps(data)) |
| assert results[0]['tool'] == 'mycheck' |
| |
| def test_non_dict_entries_skipped(self) -> None: |
| data = [{'tool': 'ci', 'status': 'pass'}, 'garbage', 42] |
| results = self._run(json.dumps(data)) |
| assert len(results) == 1 |
| |
| def test_optional_fields_default_empty(self) -> None: |
| data = [{'tool': 'ci', 'status': 'pass'}] |
| results = self._run(json.dumps(data)) |
| assert results[0]['summary'] == '' |
| assert results[0]['url'] == '' |
| assert results[0]['details'] == '' |
| |
| def test_stderr_in_error_details(self) -> None: |
| results = self._run('', stderr='something broke', ecode=1) |
| assert 'something broke' in results[0]['details'] |
| |
| def test_extra_env_set_during_run(self) -> None: |
| captured_env: Dict[str, str] = {} |
| |
| def fake_run(cmdargs: Any, stdin: Any = None, rundir: Any = None) -> Any: |
| captured_env['B4_TRACKING_FILE'] = os.environ.get('B4_TRACKING_FILE', '') |
| return (0, b'[]', b'') |
| |
| msg = _make_msg() |
| with ( |
| mock.patch('b4._run_command', side_effect=fake_run), |
| mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''), |
| ): |
| checks._run_external_cmd( |
| ['mycheck'], |
| msg, |
| '/fake', |
| extra_env={'B4_TRACKING_FILE': '/tmp/test.json'}, |
| ) |
| assert captured_env['B4_TRACKING_FILE'] == '/tmp/test.json' |
| # Env var should be cleaned up after the call |
| assert 'B4_TRACKING_FILE' not in os.environ |
| |
| def test_extra_env_restored_on_error(self) -> None: |
| msg = _make_msg() |
| os.environ['B4_TRACKING_FILE'] = 'original' |
| try: |
| with ( |
| mock.patch('b4._run_command', side_effect=RuntimeError('boom')), |
| mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''), |
| ): |
| try: |
| checks._run_external_cmd( |
| ['mycheck'], |
| msg, |
| '/fake', |
| extra_env={'B4_TRACKING_FILE': '/tmp/new.json'}, |
| ) |
| except RuntimeError: |
| pass |
| assert os.environ.get('B4_TRACKING_FILE') == 'original' |
| finally: |
| os.environ.pop('B4_TRACKING_FILE', None) |
| |
| |
| # --------------------------------------------------------------------------- |
| # _run_builtin_patchwork aggregation |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestBuiltinPatchwork: |
| """Tests for _run_builtin_patchwork status aggregation.""" |
| |
| def _run(self, pw_checks: List[Dict[str, Any]]) -> List[Dict[str, str]]: |
| msg = _make_msg(msgid='test@example.com') |
| with ( |
| mock.patch( |
| 'b4.LoreMessage.get_patchwork_data_by_msgid', return_value={'id': 42} |
| ), |
| mock.patch('b4.review.pw_fetch_checks', return_value=pw_checks), |
| ): |
| return checks._run_builtin_patchwork(msg, 'proj', 'https://pw.example.com') |
| |
| def test_all_success(self) -> None: |
| pw = [ |
| {'state': 'success', 'context': 'build', 'description': 'ok', 'url': ''}, |
| {'state': 'success', 'context': 'test', 'description': 'ok', 'url': ''}, |
| ] |
| results = self._run(pw) |
| assert len(results) == 1 |
| assert results[0]['tool'] == 'patchwork' |
| assert results[0]['status'] == 'pass' |
| |
| def test_worst_case_fail(self) -> None: |
| pw = [ |
| {'state': 'success', 'context': 'build', 'description': 'ok', 'url': ''}, |
| {'state': 'fail', 'context': 'test', 'description': 'bad', 'url': ''}, |
| ] |
| results = self._run(pw) |
| assert results[0]['status'] == 'fail' |
| |
| def test_pending_is_warn(self) -> None: |
| pw = [ |
| {'state': 'pending', 'context': 'ci', 'description': 'running', 'url': ''}, |
| ] |
| results = self._run(pw) |
| assert results[0]['status'] == 'warn' |
| |
| def test_warning_is_warn(self) -> None: |
| pw = [ |
| {'state': 'warning', 'context': 'ci', 'description': 'iffy', 'url': ''}, |
| ] |
| results = self._run(pw) |
| assert results[0]['status'] == 'warn' |
| |
| def test_details_are_json(self) -> None: |
| pw = [ |
| { |
| 'state': 'success', |
| 'context': 'build', |
| 'description': 'ok', |
| 'url': 'http://x', |
| }, |
| ] |
| results = self._run(pw) |
| details = json.loads(results[0]['details']) |
| assert isinstance(details, list) |
| assert details[0]['context'] == 'build' |
| |
| def test_no_msgid_returns_empty(self) -> None: |
| msg = EmailMessage() |
| msg['Subject'] = 'test' |
| result = checks._run_builtin_patchwork(msg, 'proj', 'https://pw.example.com') |
| assert result == [] |
| |
| def test_lookup_failure_returns_empty(self) -> None: |
| msg = _make_msg() |
| with mock.patch( |
| 'b4.LoreMessage.get_patchwork_data_by_msgid', |
| side_effect=LookupError('not found'), |
| ): |
| result = checks._run_builtin_patchwork( |
| msg, 'proj', 'https://pw.example.com' |
| ) |
| assert result == [] |
| |
| |
| # --------------------------------------------------------------------------- |
| # High-level runners |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestRunners: |
| """Tests for run_perpatch_checks and run_series_checks.""" |
| |
| def test_perpatch_dispatches_external(self) -> None: |
| msg = _make_msg() |
| data = json.dumps([{'tool': 'ci', 'status': 'pass', 'summary': 'ok'}]) |
| with ( |
| mock.patch('b4._run_command', return_value=(0, data.encode(), b'')), |
| mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''), |
| ): |
| results = checks.run_perpatch_checks([('m1@ex', msg)], ['mycheck'], '/fake') |
| assert 'm1@ex' in results |
| assert results['m1@ex'][0]['tool'] == 'ci' |
| |
| def test_perpatch_exception_captured(self) -> None: |
| msg = _make_msg() |
| with ( |
| mock.patch('b4._run_command', side_effect=RuntimeError('boom')), |
| mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''), |
| ): |
| results = checks.run_perpatch_checks([('m1@ex', msg)], ['badcmd'], '/fake') |
| assert results['m1@ex'][0]['status'] == 'fail' |
| assert 'boom' in results['m1@ex'][0]['summary'] |
| |
| def test_series_dispatches_external(self) -> None: |
| msg = _make_msg() |
| data = json.dumps([{'tool': 'series-ci', 'status': 'warn'}]) |
| with ( |
| mock.patch('b4._run_command', return_value=(0, data.encode(), b'')), |
| mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''), |
| ): |
| results = checks.run_series_checks(('cover@ex', msg), ['mycheck'], '/fake') |
| assert len(results) == 1 |
| assert results[0]['tool'] == 'series-ci' |
| |
| def test_series_exception_captured(self) -> None: |
| msg = _make_msg() |
| with ( |
| mock.patch('b4._run_command', side_effect=RuntimeError('kaboom')), |
| mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''), |
| ): |
| results = checks.run_series_checks(('cover@ex', msg), ['badcmd'], '/fake') |
| assert results[0]['status'] == 'fail' |
| assert 'kaboom' in results[0]['summary'] |
| |
| def test_dispatch_builtin_checkpatch(self) -> None: |
| msg = _make_msg() |
| with ( |
| mock.patch('os.access', return_value=True), |
| mock.patch('b4._run_command', return_value=(0, b'', b'')), |
| mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''), |
| ): |
| results = checks._dispatch_cmd('_builtin_checkpatch', msg, '/fake') |
| assert results[0]['tool'] == 'checkpatch' |
| |
| def test_dispatch_builtin_patchwork_without_config(self) -> None: |
| msg = _make_msg() |
| results = checks._dispatch_cmd('_builtin_patchwork', msg, '/fake') |
| assert results == [] |
| |
| |
| # --------------------------------------------------------------------------- |
| # _STATUS_ORDER module-level constant |
| # --------------------------------------------------------------------------- |
| |
| |
| class TestStatusOrder: |
| """Verify the module-level status ordering constant.""" |
| |
| def test_ordering(self) -> None: |
| assert checks._STATUS_ORDER['pass'] < checks._STATUS_ORDER['warn'] |
| assert checks._STATUS_ORDER['warn'] < checks._STATUS_ORDER['fail'] |
| |
| |
| # --------------------------------------------------------------------------- |
| # Sashiko AI review integration |
| # --------------------------------------------------------------------------- |
| |
| # Sample patchset response matching the real sashiko API format. |
| _SASHIKO_PATCHSET: Dict[str, Any] = { |
| 'id': 93, |
| 'message_id': 'cover@example.com', |
| 'subject': '[PATCH 0/3] Example series', |
| 'status': 'Reviewed', |
| 'author': 'Test Author <test@example.com>', |
| 'patches': [ |
| { |
| 'id': 1, |
| 'message_id': 'patch1@example.com', |
| 'part_index': 1, |
| 'subject': '[PATCH 1/3] First patch', |
| 'status': 'applied', |
| }, |
| { |
| 'id': 2, |
| 'message_id': 'patch2@example.com', |
| 'part_index': 2, |
| 'subject': '[PATCH 2/3] Second patch', |
| 'status': 'applied', |
| }, |
| { |
| 'id': 3, |
| 'message_id': 'patch3@example.com', |
| 'part_index': 3, |
| 'subject': '[PATCH 3/3] Third patch', |
| 'status': 'applied', |
| }, |
| ], |
| 'reviews': [ |
| { |
| 'id': 100, |
| 'patch_id': 1, |
| 'status': 'Reviewed', |
| 'result': 'Review completed successfully.', |
| 'summary': '', |
| 'inline_review': 'looks good', |
| 'output': json.dumps( |
| { |
| 'findings': [ |
| {'severity': 'Low', 'problem': 'Minor style issue'}, |
| ], |
| } |
| ), |
| }, |
| { |
| 'id': 101, |
| 'patch_id': 2, |
| 'status': 'Reviewed', |
| 'result': 'Review completed successfully.', |
| 'summary': '', |
| 'inline_review': 'has issues', |
| 'output': json.dumps( |
| { |
| 'findings': [ |
| { |
| 'severity': 'Critical', |
| 'problem': 'Use-after-free', |
| 'suggestion': 'Add proper locking', |
| }, |
| {'severity': 'High', 'problem': 'Missing error check'}, |
| ], |
| } |
| ), |
| }, |
| { |
| 'id': 102, |
| 'patch_id': 3, |
| 'status': 'Skipped', |
| 'result': 'Skipped: touches only ignored files', |
| 'summary': '', |
| 'inline_review': '', |
| 'output': '', |
| }, |
| ], |
| } |
| |
| |
| class TestSashikoCache: |
| """Tests for sashiko in-process patchset cache.""" |
| |
| def setup_method(self) -> None: |
| checks.clear_sashiko_cache() |
| |
| def teardown_method(self) -> None: |
| checks.clear_sashiko_cache() |
| |
| def test_clear_cache(self) -> None: |
| checks._sashiko_patchset_cache['test@ex'] = {'id': 1} |
| checks.clear_sashiko_cache() |
| assert checks._sashiko_patchset_cache == {} |
| |
| def test_fetch_caches_all_msgids(self) -> None: |
| resp = mock.Mock() |
| resp.status_code = 200 |
| resp.json.return_value = _SASHIKO_PATCHSET |
| |
| session = mock.Mock() |
| session.get.return_value = resp |
| |
| with mock.patch('b4.get_requests_session', return_value=session): |
| data = checks._fetch_sashiko_patchset( |
| 'cover@example.com', 'https://sashiko.dev' |
| ) |
| |
| assert data is not None |
| assert data['id'] == 93 |
| # All msgids should be cached |
| assert 'cover@example.com' in checks._sashiko_patchset_cache |
| assert 'patch1@example.com' in checks._sashiko_patchset_cache |
| assert 'patch2@example.com' in checks._sashiko_patchset_cache |
| assert 'patch3@example.com' in checks._sashiko_patchset_cache |
| # Second call should use cache, not network |
| session.get.reset_mock() |
| data2 = checks._fetch_sashiko_patchset( |
| 'patch2@example.com', 'https://sashiko.dev' |
| ) |
| session.get.assert_not_called() |
| assert data2 is not None |
| assert data2['id'] == 93 |
| |
| def test_fetch_404_caches_none(self) -> None: |
| resp = mock.Mock() |
| resp.status_code = 404 |
| |
| session = mock.Mock() |
| session.get.return_value = resp |
| |
| with mock.patch('b4.get_requests_session', return_value=session): |
| data = checks._fetch_sashiko_patchset( |
| 'unknown@example.com', 'https://sashiko.dev' |
| ) |
| |
| assert data is None |
| assert checks._sashiko_patchset_cache['unknown@example.com'] is None |
| |
| def test_fetch_network_error_caches_none(self) -> None: |
| import requests |
| |
| session = mock.Mock() |
| session.get.side_effect = requests.ConnectionError('offline') |
| |
| with mock.patch('b4.get_requests_session', return_value=session): |
| data = checks._fetch_sashiko_patchset( |
| 'test@example.com', 'https://sashiko.dev' |
| ) |
| |
| assert data is None |
| assert checks._sashiko_patchset_cache['test@example.com'] is None |
| |
| |
| class TestParseSashikoFindings: |
| """Tests for _parse_sashiko_findings.""" |
| |
| def test_empty_output(self) -> None: |
| assert checks._parse_sashiko_findings({'output': ''}) == [] |
| |
| def test_null_output(self) -> None: |
| assert checks._parse_sashiko_findings({'output': None}) == [] |
| |
| def test_no_output_key(self) -> None: |
| assert checks._parse_sashiko_findings({}) == [] |
| |
| def test_invalid_json(self) -> None: |
| assert checks._parse_sashiko_findings({'output': 'not json'}) == [] |
| |
| def test_critical_finding(self) -> None: |
| review = { |
| 'output': json.dumps( |
| { |
| 'findings': [{'severity': 'Critical', 'problem': 'UAF bug'}], |
| } |
| ) |
| } |
| findings = checks._parse_sashiko_findings(review) |
| assert len(findings) == 1 |
| assert findings[0]['status'] == 'fail' |
| assert findings[0]['state'] == 'critical' |
| assert 'UAF bug' in findings[0]['description'] |
| |
| def test_high_finding(self) -> None: |
| review = { |
| 'output': json.dumps( |
| { |
| 'findings': [{'severity': 'High', 'problem': 'Missing check'}], |
| } |
| ) |
| } |
| findings = checks._parse_sashiko_findings(review) |
| assert findings[0]['status'] == 'fail' |
| assert findings[0]['state'] == 'high' |
| |
| def test_medium_finding(self) -> None: |
| review = { |
| 'output': json.dumps( |
| { |
| 'findings': [ |
| {'severity': 'Medium', 'problem': 'Questionable logic'} |
| ], |
| } |
| ) |
| } |
| findings = checks._parse_sashiko_findings(review) |
| assert findings[0]['status'] == 'warn' |
| assert findings[0]['state'] == 'medium' |
| |
| def test_low_finding(self) -> None: |
| review = { |
| 'output': json.dumps( |
| { |
| 'findings': [{'severity': 'Low', 'problem': 'Style issue'}], |
| } |
| ) |
| } |
| findings = checks._parse_sashiko_findings(review) |
| assert findings[0]['status'] == 'pass' |
| assert findings[0]['state'] == 'low' |
| |
| def test_suggestion_appended(self) -> None: |
| review = { |
| 'output': json.dumps( |
| { |
| 'findings': [ |
| {'severity': 'High', 'problem': 'Bug', 'suggestion': 'Fix it'} |
| ], |
| } |
| ) |
| } |
| findings = checks._parse_sashiko_findings(review) |
| assert 'Bug' in findings[0]['description'] |
| assert 'Fix it' in findings[0]['description'] |
| |
| def test_context_includes_severity(self) -> None: |
| review = { |
| 'output': json.dumps( |
| { |
| 'findings': [{'severity': 'Medium', 'problem': 'test'}], |
| } |
| ) |
| } |
| findings = checks._parse_sashiko_findings(review) |
| assert findings[0]['context'] == 'sashiko/medium' |
| |
| def test_multiple_findings(self) -> None: |
| review = { |
| 'output': json.dumps( |
| { |
| 'findings': [ |
| {'severity': 'Critical', 'problem': 'bad'}, |
| {'severity': 'Low', 'problem': 'minor'}, |
| ], |
| } |
| ) |
| } |
| findings = checks._parse_sashiko_findings(review) |
| assert len(findings) == 2 |
| |
| def test_no_findings_key(self) -> None: |
| review = {'output': json.dumps({'fixes': []})} |
| assert checks._parse_sashiko_findings(review) == [] |
| |
| |
| class TestSashikoFindingsSummary: |
| """Tests for _sashiko_findings_summary.""" |
| |
| def test_no_findings(self) -> None: |
| worst, summary = checks._sashiko_findings_summary([]) |
| assert worst == 'pass' |
| assert summary == 'No findings' |
| |
| def test_single_critical(self) -> None: |
| findings = [{'status': 'fail', 'state': 'critical', 'description': 'bad'}] |
| worst, summary = checks._sashiko_findings_summary(findings) |
| assert worst == 'fail' |
| assert '1 critical' in summary |
| |
| def test_mixed_severities(self) -> None: |
| findings = [ |
| {'status': 'fail', 'state': 'critical', 'description': ''}, |
| {'status': 'fail', 'state': 'high', 'description': ''}, |
| {'status': 'warn', 'state': 'medium', 'description': ''}, |
| {'status': 'pass', 'state': 'low', 'description': ''}, |
| ] |
| worst, summary = checks._sashiko_findings_summary(findings) |
| assert worst == 'fail' |
| assert '1 critical' in summary |
| assert '1 high' in summary |
| assert '1 medium' in summary |
| assert '1 low' in summary |
| |
| def test_only_low_is_pass(self) -> None: |
| findings = [ |
| {'status': 'pass', 'state': 'low', 'description': ''}, |
| {'status': 'pass', 'state': 'low', 'description': ''}, |
| ] |
| worst, summary = checks._sashiko_findings_summary(findings) |
| assert worst == 'pass' |
| assert '2 low' in summary |
| |
| |
| class TestRunBuiltinSashiko: |
| """Tests for _run_builtin_sashiko end-to-end.""" |
| |
| def setup_method(self) -> None: |
| checks.clear_sashiko_cache() |
| |
| def teardown_method(self) -> None: |
| checks.clear_sashiko_cache() |
| |
| def _prefill_cache(self, patchset: Optional[Dict[str, Any]] = None) -> None: |
| """Pre-fill the cache so no HTTP calls are made.""" |
| ps = patchset if patchset is not None else _SASHIKO_PATCHSET |
| for key in [ |
| 'cover@example.com', |
| 'patch1@example.com', |
| 'patch2@example.com', |
| 'patch3@example.com', |
| ]: |
| checks._sashiko_patchset_cache[key] = ps |
| |
| def test_no_msgid_returns_empty(self) -> None: |
| msg = EmailMessage() |
| msg['Subject'] = 'test' |
| assert checks._run_builtin_sashiko(msg, 'https://sashiko.dev') == [] |
| |
| def test_not_found_returns_empty(self) -> None: |
| checks._sashiko_patchset_cache['unknown@ex'] = None |
| msg = _make_msg(msgid='unknown@ex') |
| assert checks._run_builtin_sashiko(msg, 'https://sashiko.dev') == [] |
| |
| def test_cover_letter_aggregates_all_findings(self) -> None: |
| self._prefill_cache() |
| msg = _make_msg(msgid='cover@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert len(results) == 1 |
| assert results[0]['tool'] == 'sashiko' |
| assert results[0]['status'] == 'fail' # critical finding in patch 2 |
| assert '1 critical' in results[0]['summary'] |
| assert '1 high' in results[0]['summary'] |
| assert '1 low' in results[0]['summary'] |
| assert results[0]['url'] == 'https://sashiko.dev/patch/93' |
| # Details should be valid JSON |
| details = json.loads(results[0]['details']) |
| assert len(details) == 3 # 1 low + 1 critical + 1 high |
| |
| def test_patch_with_critical_finding(self) -> None: |
| self._prefill_cache() |
| msg = _make_msg(msgid='patch2@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert results[0]['status'] == 'fail' |
| assert '1 critical' in results[0]['summary'] |
| assert '1 high' in results[0]['summary'] |
| |
| def test_patch_with_low_finding(self) -> None: |
| self._prefill_cache() |
| msg = _make_msg(msgid='patch1@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert results[0]['status'] == 'pass' |
| assert '1 low' in results[0]['summary'] |
| |
| def test_skipped_patch(self) -> None: |
| self._prefill_cache() |
| msg = _make_msg(msgid='patch3@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert results[0]['status'] == 'pass' |
| assert 'Skipped' in results[0]['summary'] |
| |
| def test_pending_patchset(self) -> None: |
| ps = dict(_SASHIKO_PATCHSET, status='Pending') |
| self._prefill_cache(ps) |
| msg = _make_msg(msgid='patch1@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert results[0]['status'] == 'warn' |
| assert 'pending' in results[0]['summary'].lower() |
| |
| def test_in_review_patchset(self) -> None: |
| ps = dict(_SASHIKO_PATCHSET, status='In Review') |
| self._prefill_cache(ps) |
| msg = _make_msg(msgid='cover@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert results[0]['status'] == 'warn' |
| assert 'in review' in results[0]['summary'].lower() |
| |
| def test_failed_patchset(self) -> None: |
| ps = dict(_SASHIKO_PATCHSET, status='Failed') |
| self._prefill_cache(ps) |
| msg = _make_msg(msgid='cover@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert results[0]['status'] == 'fail' |
| assert results[0]['summary'] == 'Failed' |
| |
| def test_failed_to_apply_patchset(self) -> None: |
| ps = dict(_SASHIKO_PATCHSET, status='Failed To Apply') |
| self._prefill_cache(ps) |
| msg = _make_msg(msgid='cover@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert results[0]['status'] == 'fail' |
| |
| def test_incomplete_patchset(self) -> None: |
| ps = dict(_SASHIKO_PATCHSET, status='Incomplete') |
| self._prefill_cache(ps) |
| msg = _make_msg(msgid='cover@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert results[0]['status'] == 'warn' |
| assert 'incomplete' in results[0]['summary'].lower() |
| |
| def test_no_findings_pass(self) -> None: |
| reviews = [ |
| { |
| 'id': 100, |
| 'patch_id': 1, |
| 'status': 'Reviewed', |
| 'result': 'Review completed successfully.', |
| 'output': json.dumps({'findings': []}), |
| } |
| ] |
| ps = dict(_SASHIKO_PATCHSET, reviews=reviews) |
| self._prefill_cache(ps) |
| msg = _make_msg(msgid='patch1@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert results[0]['status'] == 'pass' |
| assert results[0]['summary'] == 'No findings' |
| |
| def test_pending_review_for_patch(self) -> None: |
| reviews = [{'id': 100, 'patch_id': 1, 'status': 'Pending', 'output': ''}] |
| ps = dict(_SASHIKO_PATCHSET, reviews=reviews) |
| self._prefill_cache(ps) |
| msg = _make_msg(msgid='patch1@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert results[0]['status'] == 'warn' |
| assert 'in progress' in results[0]['summary'].lower() |
| |
| def test_failed_review_for_patch(self) -> None: |
| reviews = [ |
| { |
| 'id': 100, |
| 'patch_id': 1, |
| 'status': 'Failed', |
| 'result': 'Token limit exceeded', |
| 'output': '', |
| } |
| ] |
| ps = dict(_SASHIKO_PATCHSET, reviews=reviews) |
| self._prefill_cache(ps) |
| msg = _make_msg(msgid='patch1@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert results[0]['status'] == 'fail' |
| assert 'Token limit' in results[0]['summary'] |
| |
| def test_patch_not_in_sashiko(self) -> None: |
| self._prefill_cache() |
| msg = _make_msg(msgid='unknown-patch@example.com') |
| # Not in cache, will try to fetch |
| checks._sashiko_patchset_cache['unknown-patch@example.com'] = None |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert results == [] |
| |
| def test_patch_without_review(self) -> None: |
| # Patchset is reviewed but this specific patch has no review entry |
| ps = dict(_SASHIKO_PATCHSET, reviews=[]) |
| self._prefill_cache(ps) |
| msg = _make_msg(msgid='patch1@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev') |
| assert results[0]['status'] == 'pass' |
| assert results[0]['summary'] == 'No review' |
| |
| def test_url_constructed_correctly(self) -> None: |
| self._prefill_cache() |
| msg = _make_msg(msgid='patch1@example.com') |
| results = checks._run_builtin_sashiko(msg, 'https://sashiko.dev/') |
| # Trailing slash should not cause double slash |
| assert results[0]['url'] == 'https://sashiko.dev/patch/93' |
| |
| |
| class TestSashikoAutoWire: |
| """Tests for auto-wiring _builtin_sashiko in load_check_cmds.""" |
| |
| def test_sashiko_added_when_url_configured(self) -> None: |
| config = {'sashiko-url': 'https://sashiko.dev'} |
| with ( |
| mock.patch('b4.get_main_config', return_value=config), |
| mock.patch('b4.git_get_toplevel', return_value=None), |
| ): |
| perpatch, series = checks.load_check_cmds() |
| assert '_builtin_sashiko' in perpatch |
| assert '_builtin_sashiko' in series |
| |
| def test_sashiko_not_added_without_url(self) -> None: |
| config: Dict[str, Any] = {} |
| with ( |
| mock.patch('b4.get_main_config', return_value=config), |
| mock.patch('b4.git_get_toplevel', return_value=None), |
| ): |
| perpatch, series = checks.load_check_cmds() |
| assert '_builtin_sashiko' not in perpatch |
| assert '_builtin_sashiko' not in series |
| |
| def test_sashiko_not_duplicated(self) -> None: |
| config = { |
| 'sashiko-url': 'https://sashiko.dev', |
| 'review-perpatch-check-cmd': ['_builtin_sashiko'], |
| 'review-series-check-cmd': ['_builtin_sashiko'], |
| } |
| with ( |
| mock.patch('b4.get_main_config', return_value=config), |
| mock.patch('b4.git_get_toplevel', return_value=None), |
| ): |
| perpatch, series = checks.load_check_cmds() |
| assert perpatch.count('_builtin_sashiko') == 1 |
| assert series.count('_builtin_sashiko') == 1 |
| |
| |
| class TestSashikoDispatch: |
| """Tests for _dispatch_cmd routing to _builtin_sashiko.""" |
| |
| def test_dispatch_routes_to_sashiko(self) -> None: |
| checks.clear_sashiko_cache() |
| checks._sashiko_patchset_cache['test@ex'] = _SASHIKO_PATCHSET |
| msg = _make_msg(msgid='test@ex') |
| # Pre-cache so no HTTP call is made; use cover msgid |
| checks._sashiko_patchset_cache['test@ex'] = dict( |
| _SASHIKO_PATCHSET, message_id='test@ex' |
| ) |
| config = {'sashiko-url': 'https://sashiko.dev'} |
| with mock.patch('b4.get_main_config', return_value=config): |
| results = checks._dispatch_cmd('_builtin_sashiko', msg, '/fake') |
| assert results[0]['tool'] == 'sashiko' |
| checks.clear_sashiko_cache() |
| |
| def test_dispatch_without_config(self) -> None: |
| msg = _make_msg() |
| config: Dict[str, Any] = {} |
| with mock.patch('b4.get_main_config', return_value=config): |
| results = checks._dispatch_cmd('_builtin_sashiko', msg, '/fake') |
| assert results == [] |