blob: 1469bf3b95f976880ee744f0cc1362aa88e6fb01 [file] [edit]
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 == []