blob: be132ff5ab5a5e03db9335027b716bee3afa5430 [file] [edit]
import os
import pathlib
from email.message import EmailMessage
from typing import Any, List, Optional, Tuple
from unittest import mock
import pytest
import b4
import b4.ty
def _review_sections() -> List[Tuple[str, List[str]]]:
return [
(
'[PATCH 0/2] Add frobnicator support',
[
'From: Foo Bar <foo@example.com>',
'Sent: Mon, 1 Jan 2026 00:00:00 +0000',
'Link: https://lore.kernel.org/r/cover-1@example.com',
'---',
'[1/2] commit-id: 1a2b3c4d5e6f',
'[2/2] commit-id: 2b3c4d5e6f70',
'---',
'Applied: Wed, 3 Jan 2026 12:00:00 -0500',
],
),
(
'[GIT PULL] frobnicator updates',
[
'From: Bar Foo <bar@example.com>',
'Sent: Tue, 2 Jan 2026 00:00:00 +0000',
'Link: https://lore.kernel.org/r/pull-2@example.com',
'---',
'merge-commit: 9f8e7d6c5b4a',
'---',
'Applied: Thu, 4 Jan 2026 09:30:00 -0500',
],
),
]
def test_render_ty_review_layout() -> None:
"""Each item is offered with a leading '+' on its subject line, with its
From/Date/Link shown as '#' detail comments; a pristine buffer skips none.
"""
sections = _review_sections()
buf = b4.ty.render_ty_review(sections)
text = buf.decode('utf-8')
assert '+ [PATCH 0/2] Add frobnicator support' in text
assert '+ [GIT PULL] frobnicator updates' in text
# Details are '#' comments, not markable item lines.
assert ' # From: Foo Bar <foo@example.com>' in text
assert ' # Sent: Mon, 1 Jan 2026 00:00:00 +0000' in text
assert ' # Link: https://lore.kernel.org/r/cover-1@example.com' in text
# The resolved commit-ids sit between '---' separators, Applied at the end.
assert ' # ---' in text
assert ' # [1/2] commit-id: 1a2b3c4d5e6f' in text
assert ' # merge-commit: 9f8e7d6c5b4a' in text
assert ' # Applied: Wed, 3 Jan 2026 12:00:00 -0500' in text
# A freshly rendered buffer skips nothing.
assert b4.ty.parse_ty_review(buf, sections) == set()
def test_parse_ty_review_marks_skips() -> None:
"""Flipping '+' to 'x' on an item marks it (by position) skipped."""
sections = _review_sections()
text = b4.ty.render_ty_review(sections).decode('utf-8')
text = text.replace('+ [GIT PULL]', 'x [GIT PULL]')
skipped = b4.ty.parse_ty_review(text.encode('utf-8'), sections)
assert skipped == {1}
def test_parse_ty_review_rejects_edited_subject() -> None:
"""Editing an item subject breaks the positional contract and aborts."""
sections = _review_sections()
text = b4.ty.render_ty_review(sections).decode('utf-8')
text = text.replace('Add frobnicator support', 'Add frobnicator SUPPORT')
with pytest.raises(ValueError):
b4.ty.parse_ty_review(text.encode('utf-8'), sections)
def test_parse_ty_review_rejects_count_mismatch() -> None:
"""Removing an item line entirely also aborts (ambiguous edit)."""
sections = _review_sections()
text = b4.ty.render_ty_review(sections).decode('utf-8')
text = text.replace('+ [GIT PULL] frobnicator updates\n', '')
with pytest.raises(ValueError):
b4.ty.parse_ty_review(text.encode('utf-8'), sections)
def test_parse_ty_review_rejects_reorder() -> None:
"""Reordering the items aborts: subjects no longer match by position."""
sections = _review_sections()
reordered: List[Tuple[str, List[str]]] = [
sections[1],
sections[0],
]
buf = b4.ty.render_ty_review(reordered)
with pytest.raises(ValueError):
b4.ty.parse_ty_review(buf, sections)
def test_interactive_ty_review_drops_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
"""Marking an item 'x' drops it from the returned list; the kept item
survives, in order. Skipped items are simply omitted (no persistence).
"""
applied: List[b4.ty.JsonDictT] = [
{
'subject': '[PATCH 0/2] Add frobnicator support',
'fromname': 'Foo Bar',
'fromemail': 'foo@example.com',
'sentdate': 'Mon, 1 Jan 2026 00:00:00 +0000',
'msgid': 'cover-1@example.com',
'trackfile': 'aaa.am',
},
{
'subject': '[GIT PULL] frobnicator updates',
'fromname': 'Bar Foo',
'fromemail': 'bar@example.com',
'sentdate': 'Tue, 2 Jan 2026 00:00:00 +0000',
'msgid': 'pull-2@example.com',
'trackfile': 'bbb.pr',
},
]
def fake_edit(
bdata: bytes, filehint: str = 'COMMIT_EDITMSG', **kwargs: Any
) -> bytes:
# Maintainer skips the pull request, keeps the patch series.
text = bdata.decode('utf-8').replace('+ [GIT PULL]', 'x [GIT PULL]')
return text.encode('utf-8')
monkeypatch.setattr(b4, 'edit_in_editor', fake_edit)
kept = b4.ty.interactive_ty_review(applied, None)
assert [jd['subject'] for jd in kept] == ['[PATCH 0/2] Add frobnicator support']
def test_interactive_ty_review_keeps_all_when_pristine(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An unedited buffer keeps the full list unchanged, in order."""
applied: List[b4.ty.JsonDictT] = [
{
'subject': '[PATCH 0/2] Add frobnicator support',
'fromname': 'Foo Bar',
'fromemail': 'foo@example.com',
'sentdate': 'Mon, 1 Jan 2026 00:00:00 +0000',
'msgid': 'cover-1@example.com',
'trackfile': 'aaa.am',
},
{
'subject': '[GIT PULL] frobnicator updates',
'fromname': 'Bar Foo',
'fromemail': 'bar@example.com',
'sentdate': 'Tue, 2 Jan 2026 00:00:00 +0000',
'msgid': 'pull-2@example.com',
'trackfile': 'bbb.pr',
},
]
def fake_edit(
bdata: bytes, filehint: str = 'COMMIT_EDITMSG', **kwargs: Any
) -> bytes:
return bdata
monkeypatch.setattr(b4, 'edit_in_editor', fake_edit)
kept = b4.ty.interactive_ty_review(applied, None)
assert kept == applied
def test_interactive_ty_review_edits_in_the_named_tree(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""'b4 ty -g' names the tree the thank-yous belong to, so the editor runs
there: a repository-local core.editor is the one that applies, and the
scratch file lands inside that tree rather than wherever the process was
started from."""
applied: List[b4.ty.JsonDictT] = [
{
'subject': '[PATCH] Add frobnicator support',
'fromname': 'Foo Bar',
'fromemail': 'foo@example.com',
'sentdate': 'Mon, 1 Jan 2026 00:00:00 +0000',
'msgid': 'patch-1@example.com',
'trackfile': 'aaa.am',
},
]
seen: List[Optional[str]] = []
def fake_edit(
bdata: bytes,
filehint: str = 'COMMIT_EDITMSG',
*,
topdir: Optional[str] = None,
guard_branch: bool = False,
) -> bytes:
seen.append(topdir)
return bdata
monkeypatch.setattr(b4, 'edit_in_editor', fake_edit)
b4.ty.interactive_ty_review(applied, '/some/other/tree')
assert seen == ['/some/other/tree']
def test_get_applied_info_picks_latest_and_lists_commits(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""For a series: one '[N/total] commit-id' line per matched patch (gaps for
unmatched ones), and the latest committer date as the applied date.
"""
by_commit = {
'aaaaaaaaaaaa': '1750000000\x00Sun, 15 Jun 2026 00:00:00 -0500\x00aaaaaaaaaaaa',
'cccccccccccc': '1750200000\x00Tue, 17 Jun 2026 12:00:00 -0400\x00cccccccccccc',
}
def fake_lines(gitdir: Optional[str], args: List[str]) -> List[str]:
assert args[:3] == ['show', '-s', '--format=%ct%x00%cD%x00%h']
return [by_commit[args[3]]]
monkeypatch.setattr(b4, 'git_get_command_lines', fake_lines)
# Patch 2/3 did not match (None) -- it should be skipped, leaving a gap.
jsondata: b4.ty.JsonDictT = {
'commits': [[1, 'aaaaaaaaaaaa'], [2, None], [3, 'cccccccccccc']],
}
applied_date, commit_lines = b4.ty.get_applied_info(None, jsondata)
assert commit_lines == [
'[1/3] commit-id: aaaaaaaaaaaa',
'[3/3] commit-id: cccccccccccc',
]
assert applied_date == 'Tue, 17 Jun 2026 12:00:00 -0400'
def test_get_applied_info_pull_request(monkeypatch: pytest.MonkeyPatch) -> None:
"""For a pull request: a single 'merge-commit' line and its date."""
def fake_lines(gitdir: Optional[str], args: List[str]) -> List[str]:
assert args[3] == 'merge1234567'
return ['1750200000\x00Tue, 17 Jun 2026 12:00:00 -0400\x00merge1234567']
monkeypatch.setattr(b4, 'git_get_command_lines', fake_lines)
jsondata: b4.ty.JsonDictT = {'merge_commit_id': 'merge1234567'}
applied_date, commit_lines = b4.ty.get_applied_info(None, jsondata)
assert commit_lines == ['merge-commit: merge1234567']
assert applied_date == 'Tue, 17 Jun 2026 12:00:00 -0400'
def test_get_applied_info_none_without_commits() -> None:
"""No recorded commit-ids means no applied date and no commit lines."""
assert b4.ty.get_applied_info(None, {'commits': [[1, None]]}) == (None, [])
assert b4.ty.get_applied_info(None, {}) == (None, [])
@pytest.mark.parametrize(
'checkurl,repo,commit',
[
(
'https://git.kernel.org/pub/scm/linux/kernel/git/broonie/misc.git/commit/?id=6c2505e185b0',
'https://git.kernel.org/pub/scm/linux/kernel/git/broonie/misc.git',
'6c2505e185b0',
),
(
'https://host.example/repo.git/commit/?h=for-next&id=abcdef123456',
'https://host.example/repo.git',
'abcdef123456',
),
(
'https://github.com/user/repo/commit/0123456789abcdef',
'https://github.com/user/repo',
'0123456789abcdef',
),
(
'https://gitlab.com/group/repo/-/commit/0123456789abcdef',
'https://gitlab.com/group/repo',
'0123456789abcdef',
),
# git.kernel.org shortlink: commit recoverable, repo is not
('https://git.kernel.org/username/c/abc123def456', None, 'abc123def456'),
('https://example.com/whatever', None, None),
],
)
def test_parse_checkurl(
checkurl: str, repo: Optional[str], commit: Optional[str]
) -> None:
assert b4.ty._parse_checkurl(checkurl) == (repo, commit)
def test_get_check_repo_config_override(monkeypatch: pytest.MonkeyPatch) -> None:
"""b4.thanks-check-repo wins over URL derivation."""
checkurl = 'https://github.com/user/repo/commit/0123456789abcdef'
assert b4.ty._get_check_repo(checkurl) == 'https://github.com/user/repo'
monkeypatch.setitem(
b4.MAIN_CONFIG, 'thanks-check-repo', 'https://example.com/r.git'
)
assert b4.ty._get_check_repo(checkurl) == 'https://example.com/r.git'
def _init_repo(path: str) -> None:
ecode, out = b4.git_run_command(None, ['init', path])
assert ecode == 0, out
b4.git_set_config(path, 'user.name', 'Test')
b4.git_set_config(path, 'user.email', 'test@example.com')
def _commit_empty(msg: str) -> str:
ecode, out = b4.git_run_command(None, ['commit', '--allow-empty', '-m', msg])
assert ecode == 0, out
ecode, out = b4.git_run_command(None, ['rev-parse', 'HEAD'])
assert ecode == 0, out
return out.strip()
def test_commit_reachable_on_remote(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""Reachability from an advertised head is what makes a commit
published; mere presence in the remote odb is not enough."""
local = str(tmp_path / 'local')
pub = str(tmp_path / 'pub')
_init_repo(local)
ecode, out = b4.git_run_command(None, ['init', '--bare', pub])
assert ecode == 0, out
monkeypatch.chdir(local)
c1 = _commit_empty('c1')
ecode, out = b4.git_run_command(None, ['push', pub, 'HEAD:refs/heads/master'])
assert ecode == 0, out
c2 = _commit_empty('c2')
# c1 is on the remote's master; c2 exists only locally
assert b4.ty.commit_reachable_on_remote(c1, pub) is True
assert b4.ty.commit_reachable_on_remote(c2, pub) is False
# Emulate shared object storage: c2's object is in the remote odb
# (push a ref, then delete it) but no head reaches it
ecode, out = b4.git_run_command(None, ['push', pub, f'{c2}:refs/heads/tmp'])
assert ecode == 0, out
ecode, out = b4.git_run_command(None, ['push', pub, ':refs/heads/tmp'])
assert ecode == 0, out
assert b4.ty.commit_reachable_on_remote(c2, pub) is False
# Unreachable remote: undeterminable, not a verdict
assert b4.ty.commit_reachable_on_remote(c1, str(tmp_path / 'nope')) is None
def test_commit_reachable_unknown_tips(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""Advertised tips we have no objects for cannot prove anything, so
the answer is 'don't know' -- which keeps the message queued, but
reports a failed check rather than normal waiting."""
local = str(tmp_path / 'local')
pub = str(tmp_path / 'pub')
other = str(tmp_path / 'other')
_init_repo(local)
# -b master: the clone below takes its HEAD from this repository, and an
# init.defaultBranch of 'main' would leave it on an unborn branch, with
# the "advance the remote" commit landing as an unrelated root commit.
ecode, out = b4.git_run_command(None, ['init', '--bare', '-b', 'master', pub])
assert ecode == 0, out
monkeypatch.chdir(local)
c1 = _commit_empty('c1')
ecode, out = b4.git_run_command(None, ['push', pub, 'HEAD:refs/heads/master'])
assert ecode == 0, out
# Advance the remote's master from a different clone
ecode, out = b4.git_run_command(None, ['clone', pub, other])
assert ecode == 0, out
b4.git_set_config(other, 'user.name', 'Test')
b4.git_set_config(other, 'user.email', 'test@example.com')
monkeypatch.chdir(other)
_commit_empty('c2-elsewhere')
ecode, out = b4.git_run_command(None, ['push', 'origin', 'HEAD:refs/heads/master'])
assert ecode == 0, out
monkeypatch.chdir(local)
# c1 is actually published, but the only advertised tip is unknown here
assert b4.ty.commit_reachable_on_remote(c1, pub) is None
def test_commit_reachable_branch_filter(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""With a branch, only that branch qualifies when advertised; an
unadvertised branch falls back to checking all heads."""
local = str(tmp_path / 'local')
pub = str(tmp_path / 'pub')
_init_repo(local)
ecode, out = b4.git_run_command(None, ['init', '--bare', pub])
assert ecode == 0, out
monkeypatch.chdir(local)
c1 = _commit_empty('c1')
ecode, out = b4.git_run_command(None, ['push', pub, 'HEAD:refs/heads/master'])
assert ecode == 0, out
c2 = _commit_empty('c2')
ecode, out = b4.git_run_command(None, ['push', pub, f'{c2}:refs/heads/side'])
assert ecode == 0, out
# c2 is only on 'side': published for 'side' and for the branchless
# check, but not yet for the branch the message claims
assert b4.ty.commit_reachable_on_remote(c2, pub) is True
assert b4.ty.commit_reachable_on_remote(c2, pub, branch='side') is True
assert b4.ty.commit_reachable_on_remote(c2, pub, branch='master') is False
assert b4.ty.commit_reachable_on_remote(c1, pub, branch='master') is True
# Unadvertised branch (renamed/deleted): any head counts again
assert b4.ty.commit_reachable_on_remote(c2, pub, branch='gone') is True
def test_commit_reachable_uses_the_gitdir_it_is_given(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""Ancestry is computed in the named repository, not the process cwd.
'b4 review cron' sweeps several projects from whatever directory the
scheduler happened to start it in, so the cwd is nobody's repository."""
local = str(tmp_path / 'local')
pub = str(tmp_path / 'pub')
elsewhere = str(tmp_path / 'elsewhere')
_init_repo(local)
_init_repo(elsewhere)
ecode, out = b4.git_run_command(None, ['init', '--bare', pub])
assert ecode == 0, out
monkeypatch.chdir(local)
c1 = _commit_empty('c1')
ecode, out = b4.git_run_command(None, ['push', pub, 'HEAD:refs/heads/master'])
assert ecode == 0, out
# An unrelated cwd knows none of the advertised tips, so on its own it
# cannot answer -- the objects live in 'local'.
monkeypatch.chdir(elsewhere)
assert b4.ty.commit_reachable_on_remote(c1, pub, branch='master') is None
assert (
b4.ty.commit_reachable_on_remote(c1, pub, branch='master', gitdir=local) is True
)
def test_get_check_repo_for_branch_priority(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""Check-repo resolution: per-remote b4-check-repo, then the
b4.thanks-check-repo config, then the remote URL, then the mask."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
_commit_empty('c1')
b4.git_set_config(repo, 'remote.spi.url', 'https://example.com/spi.git')
b4.git_set_config(repo, 'branch.for-next.remote', 'spi')
b4.git_set_config(repo, 'branch.for-next.merge', 'refs/heads/for-next')
checkurl = 'https://github.com/user/repo/commit/0123456789abcdef'
# No overrides: the branch's remote URL wins over mask derivation
assert (
b4.ty.get_check_repo_for_branch(repo, 'for-next', checkurl)
== 'https://example.com/spi.git'
)
# A branch with no remote falls back to the mask-derived repo
assert (
b4.ty.get_check_repo_for_branch(repo, 'orphan', checkurl)
== 'https://github.com/user/repo'
)
# b4.thanks-check-repo beats the remote URL
monkeypatch.setitem(
b4.MAIN_CONFIG, 'thanks-check-repo', 'https://example.com/g.git'
)
assert (
b4.ty.get_check_repo_for_branch(repo, 'for-next', checkurl)
== 'https://example.com/g.git'
)
# remote.<name>.b4-check-repo beats everything
b4.git_set_config(repo, 'remote.spi.b4-check-repo', 'https://example.com/pub.git')
assert (
b4.ty.get_check_repo_for_branch(repo, 'for-next', checkurl)
== 'https://example.com/pub.git'
)
def test_queue_message_check_headers(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""Queueing records X-Check-Commit/X-Check-Repo alongside X-Check-URL."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
fullsha = 'ab12' * 10
checkurl = f'https://git.kernel.org/pub/scm/utils/b4/b4.git/commit/?id={fullsha}'
msg = EmailMessage()
msg['Subject'] = 'Re: [PATCH] test'
msg.set_content('Thanks!')
b4.ty.queue_message(msg, checkurl, 'test-change-id', 1)
qdir = b4.ty._get_queue_dir()
parsed = b4.ty._parse_queue_file(os.path.join(qdir, 'test-change-id-v1.msg'))
assert parsed is not None
assert parsed['X-Check-URL'] == checkurl
assert parsed['X-Check-Commit'] == fullsha
assert parsed['X-Check-Repo'] == 'https://git.kernel.org/pub/scm/utils/b4/b4.git'
def test_queue_message_shortlink_mask(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""With a shortlink mask the repo comes from b4.thanks-check-repo and
the commit from the explicit checkcommit argument."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
monkeypatch.setitem(
b4.MAIN_CONFIG, 'thanks-check-repo', 'https://example.com/r.git'
)
fullsha = 'cd34' * 10
msg = EmailMessage()
msg['Subject'] = 'Re: [PATCH] test'
msg.set_content('Thanks!')
checkurl = f'https://git.kernel.org/username/c/{fullsha[:12]}'
b4.ty.queue_message(msg, checkurl, 'test-change-id', 2, checkcommit=fullsha)
qdir = b4.ty._get_queue_dir()
parsed = b4.ty._parse_queue_file(os.path.join(qdir, 'test-change-id-v2.msg'))
assert parsed is not None
assert parsed['X-Check-Commit'] == fullsha
assert parsed['X-Check-Repo'] == 'https://example.com/r.git'
def test_queue_message_explicit_repo_and_branch(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""Explicit checkrepo/checkbranch are recorded verbatim, bypassing
mask derivation entirely (the shortlink-mask case)."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
fullsha = 'fa11' * 10
msg = EmailMessage()
msg['Subject'] = 'Re: [PATCH] test'
msg.set_content('Thanks!')
checkurl = f'https://git.kernel.org/username/c/{fullsha[:12]}'
b4.ty.queue_message(
msg,
checkurl,
'test-change-id',
3,
checkcommit=fullsha,
checkrepo='https://example.com/spi.git',
checkbranch='for-next',
)
qdir = b4.ty._get_queue_dir()
parsed = b4.ty._parse_queue_file(os.path.join(qdir, 'test-change-id-v3.msg'))
assert parsed is not None
assert parsed['X-Check-Commit'] == fullsha
assert parsed['X-Check-Repo'] == 'https://example.com/spi.git'
assert parsed['X-Check-Branch'] == 'for-next'
def test_process_queue_passes_branch(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""Delivery verifies reachability from the exact branch the thanks
message names, when the queue entry recorded one."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
fullsha = 'ba55' * 10
msg = EmailMessage()
msg['Subject'] = 'Re: [PATCH] test'
msg.set_content('Thanks!')
b4.ty.queue_message(
msg,
f'https://git.kernel.org/username/c/{fullsha[:12]}',
'test-change-id',
1,
checkcommit=fullsha,
checkrepo='https://example.com/spi.git',
checkbranch='for-next',
)
calls: List[Tuple[str, str, str]] = []
def fake_reachable(
commit: str, repo_url: str, branch: str = '', gitdir: Optional[str] = None
) -> Optional[bool]:
calls.append((commit, repo_url, branch))
return False
monkeypatch.setattr(b4.ty, 'commit_reachable_on_remote', fake_reachable)
delivered, pending, dseries = b4.ty.process_queue()
assert (delivered, pending, dseries) == (0, 1, [])
assert calls == [(fullsha, 'https://example.com/spi.git', 'for-next')]
def test_process_queue_holds_unpublished(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""A queued message whose commit is not reachable on the public repo
stays queued, and the reachability check gets the header values."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
fullsha = 'ef56' * 10
checkurl = f'https://git.kernel.org/pub/scm/utils/b4/b4.git/commit/?id={fullsha}'
msg = EmailMessage()
msg['Subject'] = 'Re: [PATCH] test'
msg.set_content('Thanks!')
b4.ty.queue_message(msg, checkurl, 'test-change-id', 1)
calls: List[Tuple[str, str]] = []
def fake_reachable(
commit: str, repo_url: str, branch: str = '', gitdir: Optional[str] = None
) -> Optional[bool]:
calls.append((commit, repo_url))
return False
monkeypatch.setattr(b4.ty, 'commit_reachable_on_remote', fake_reachable)
delivered, pending, dseries = b4.ty.process_queue()
assert (delivered, pending, dseries) == (0, 1, [])
assert calls == [(fullsha, 'https://git.kernel.org/pub/scm/utils/b4/b4.git')]
qdir = b4.ty._get_queue_dir()
assert os.path.exists(os.path.join(qdir, 'test-change-id-v1.msg'))
def test_queue_message_atomic_write(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""Queue files appear atomically: no temp leftovers in the queue dir."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
msg = EmailMessage()
msg['Subject'] = 'Re: [PATCH] test'
msg.set_content('Thanks!')
b4.ty.queue_message(msg, 'https://example.com/c/abcdef123456', 'cid', 1)
qdir = b4.ty._get_queue_dir()
entries = os.listdir(qdir)
assert 'cid-v1.msg' in entries
assert not [f for f in entries if f.endswith('.tmp')]
def _queue_test_message(change_id: str = 'test-change-id', revision: int = 1) -> str:
"""Queue a minimal thanks message; returns the expected full sha."""
fullsha = '9a8b' * 10
checkurl = f'https://git.kernel.org/pub/scm/utils/b4/b4.git/commit/?id={fullsha}'
msg = EmailMessage()
msg['Subject'] = 'Re: [PATCH] test'
msg.set_content('Thanks!')
b4.ty.queue_message(msg, checkurl, change_id, revision)
return fullsha
def test_process_queue_lock_held(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""A second delivery run must fail fast while the lock is held, and
succeed normally once it is released."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
_queue_test_message()
monkeypatch.setattr(
b4.ty,
'commit_reachable_on_remote',
lambda commit, repo_url, branch='', gitdir=None: False,
)
with b4.lockfile_nb(b4.ty._get_queue_lock_path()):
with pytest.raises(b4.LockHeldError):
b4.ty.process_queue()
# After release, the queue is processable again
assert b4.ty.process_queue() == (0, 1, [])
def test_process_queue_check_only(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""check_only reports what would be delivered without sending or
moving anything."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
_queue_test_message()
monkeypatch.setattr(
b4.ty,
'commit_reachable_on_remote',
lambda commit, repo_url, branch='', gitdir=None: True,
)
def _no_send(dryrun: bool = False) -> Tuple[None, str]:
raise AssertionError('check_only must not open an smtp connection')
monkeypatch.setattr(b4, 'get_smtp', _no_send)
statuses: List[str] = []
delivered, pending, dseries = b4.ty.process_queue(
check_only=True, progress_cb=lambda c, t, s: statuses.append(s)
)
assert (delivered, pending) == (1, 0)
assert dseries == [('test-change-id', 1)]
assert 'Would deliver: Re: [PATCH] test' in statuses
qdir = b4.ty._get_queue_dir()
assert os.path.exists(os.path.join(qdir, 'test-change-id-v1.msg'))
def test_process_queue_explicit_topdir(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""The queue of an explicitly-given repository is processed even when
the current directory is not inside it (cron -i __all__)."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
_queue_test_message()
monkeypatch.setattr(
b4.ty,
'commit_reachable_on_remote',
lambda commit, repo_url, branch='', gitdir=None: True,
)
outside = tmp_path / 'elsewhere'
outside.mkdir()
monkeypatch.chdir(str(outside))
assert b4.ty.get_queued_count() == 0
assert b4.ty.get_queued_count(topdir=repo) == 1
delivered, pending, _dseries = b4.ty.process_queue(check_only=True, topdir=repo)
assert (delivered, pending) == (1, 0)
def test_process_queue_finalizes_thanked(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""Delivering a queued message marks the series 'thanked' in the
tracking database."""
import b4.review.tracking as tracking
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
conn = tracking.init_db('cronproj')
tracking.add_series_to_db(
conn,
'test-change-id',
1,
'test subject',
'Test',
't@example.com',
None,
'<msg@id>',
1,
)
conn.commit()
conn.close()
_queue_test_message()
monkeypatch.setattr(
b4.ty,
'commit_reachable_on_remote',
lambda commit, repo_url, branch='', gitdir=None: True,
)
monkeypatch.setattr(b4, 'get_smtp', lambda dryrun=False: (None, 't@example.com'))
monkeypatch.setattr(b4, 'send_mail', lambda *args, **kwargs: 1)
delivered, pending, dseries = b4.ty.process_queue(identifier='cronproj')
assert (delivered, pending) == (1, 0)
assert dseries == [('test-change-id', 1)]
conn = tracking.get_db('cronproj')
row = conn.execute(
'SELECT status FROM series WHERE change_id = ?', ('test-change-id',)
).fetchone()
conn.close()
assert row[0] == 'thanked'
qdir = b4.ty._get_queue_dir()
assert not os.path.exists(os.path.join(qdir, 'test-change-id-v1.msg'))
assert os.path.exists(os.path.join(qdir, 'sent', 'test-change-id-v1.msg'))
def _queue_archive_after_message(
change_id: str = 'test-change-id', revision: int = 1
) -> None:
"""Queue a minimal thanks message with the archive-after-send flag."""
fullsha = '9a8b' * 10
checkurl = f'https://git.kernel.org/pub/scm/utils/b4/b4.git/commit/?id={fullsha}'
msg = EmailMessage()
msg['Subject'] = 'Re: [PATCH] test'
msg.set_content('Thanks!')
b4.ty.queue_message(msg, checkurl, change_id, revision, archive_after=True)
def _add_tracked_series(
identifier: str,
change_id: str = 'test-change-id',
revision: int = 1,
status: str = 'accepted',
revisions: Optional[List[int]] = None,
) -> None:
"""Seed a tracking database with one series in the given status."""
import b4.review.tracking as tracking
conn = tracking.init_db(identifier)
tracking.add_series_to_db(
conn,
change_id,
revision,
'test subject',
'Test',
't@example.com',
None,
'<msg@id>',
1,
)
tracking.update_series_status(conn, change_id, status, revision=revision)
for rv in revisions or []:
tracking.add_revision(conn, change_id, rv, f'<v{rv}@id>')
conn.commit()
conn.close()
def _series_status(identifier: str, change_id: str = 'test-change-id') -> str:
import b4.review.tracking as tracking
conn = tracking.get_db(identifier)
row = conn.execute(
'SELECT status FROM series WHERE change_id = ?', (change_id,)
).fetchone()
conn.close()
return str(row[0])
def _mock_delivery(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
b4.ty,
'commit_reachable_on_remote',
lambda commit, repo_url, branch='', gitdir=None: True,
)
monkeypatch.setattr(b4, 'get_smtp', lambda dryrun=False: (None, 't@example.com'))
monkeypatch.setattr(b4, 'send_mail', lambda *args, **kwargs: 1)
def test_queue_message_archive_after_header(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""archive_after rides on the queued message as an internal header;
without it the header is absent."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
qdir = b4.ty._get_queue_dir()
_queue_test_message('plain-cid')
parsed = b4.ty._parse_queue_file(os.path.join(qdir, 'plain-cid-v1.msg'))
assert parsed is not None
assert 'X-B4-Archive-After-Send' not in parsed
_queue_archive_after_message('archive-cid')
parsed = b4.ty._parse_queue_file(os.path.join(qdir, 'archive-cid-v1.msg'))
assert parsed is not None
assert parsed['X-B4-Archive-After-Send'] == 'yes'
def test_process_queue_archives_after_send(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""A delivered message with the archive-after flag archives the series,
and the internal header does not leak into the sent mail."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
_add_tracked_series('cronproj')
_queue_archive_after_message()
_mock_delivery(monkeypatch)
sent_msgs: List[EmailMessage] = []
def _capture_send(smtp: object, msgs: List[EmailMessage], **kwargs: object) -> int:
sent_msgs.extend(msgs)
return len(msgs)
monkeypatch.setattr(b4, 'send_mail', _capture_send)
statuses: List[str] = []
delivered, pending, _dseries = b4.ty.process_queue(
identifier='cronproj', progress_cb=lambda c, t, s: statuses.append(s)
)
assert (delivered, pending) == (1, 0)
assert _series_status('cronproj') == 'archived'
assert 'Re: [PATCH] test + archived' in statuses
assert len(sent_msgs) == 1
assert 'X-B4-Archive-After-Send' not in sent_msgs[0]
def test_process_queue_keeps_series_with_newer_revision(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""The archive is skipped when a newer revision is known by delivery
time; the series stays 'thanked' for the maintainer to deal with."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
_add_tracked_series('cronproj', revisions=[1, 2])
_queue_archive_after_message()
_mock_delivery(monkeypatch)
statuses: List[str] = []
delivered, _pending, _dseries = b4.ty.process_queue(
identifier='cronproj', progress_cb=lambda c, t, s: statuses.append(s)
)
assert delivered == 1
assert _series_status('cronproj') == 'thanked'
assert 'Re: [PATCH] test (not archived: newer revision available)' in statuses
def test_process_queue_keeps_series_after_status_drift(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""The archive is skipped when the series status drifted away from
'accepted' between queueing and delivery (e.g. back to reviewing)."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
_add_tracked_series('cronproj', status='reviewing')
_queue_archive_after_message()
_mock_delivery(monkeypatch)
statuses: List[str] = []
delivered, _pending, _dseries = b4.ty.process_queue(
identifier='cronproj', progress_cb=lambda c, t, s: statuses.append(s)
)
assert delivered == 1
assert _series_status('cronproj') != 'archived'
assert (
'Re: [PATCH] test (not archived: series status changed since queueing)'
in statuses
)
def test_process_queue_does_not_resurrect_archived(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""Delivering a queued thanks for a series that was manually archived
in the meantime must not flip it back to 'thanked'."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
_add_tracked_series('cronproj', status='archived')
_queue_test_message()
_mock_delivery(monkeypatch)
delivered, _pending, _dseries = b4.ty.process_queue(identifier='cronproj')
assert delivered == 1
assert _series_status('cronproj') == 'archived'
def test_process_queue_skips_archive_when_checked_out(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""A checked-out review branch is left alone by delivery cleanup:
no tracking-commit write, no archive. The DB still records
'thanked' — the fact of the sent thanks."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
_commit_empty('base')
_add_tracked_series('cronproj')
review_branch = 'b4/review/test-change-id'
ecode, out = b4.git_run_command(None, ['checkout', '-b', review_branch])
assert ecode == 0, out
_queue_archive_after_message()
_mock_delivery(monkeypatch)
statuses: List[str] = []
with mock.patch('b4.review.update_tracking_status') as mock_uts:
delivered, _pending, _dseries = b4.ty.process_queue(
identifier='cronproj',
topdir=repo,
progress_cb=lambda c, t, s: statuses.append(s),
)
assert delivered == 1
mock_uts.assert_not_called()
assert _series_status('cronproj') == 'thanked'
assert 'Re: [PATCH] test (not archived: branch is checked out)' in statuses
assert b4.git_branch_exists(None, review_branch)
def test_process_queue_updates_parked_branch(
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
) -> None:
"""Control: with the review branch parked, delivery still writes the
'thanked' status to its tracking commit."""
repo = str(tmp_path / 'repo')
_init_repo(repo)
monkeypatch.chdir(repo)
_commit_empty('base')
_add_tracked_series('cronproj')
review_branch = 'b4/review/test-change-id'
ecode, out = b4.git_run_command(None, ['branch', review_branch])
assert ecode == 0, out
_queue_test_message()
_mock_delivery(monkeypatch)
with mock.patch('b4.review.update_tracking_status') as mock_uts:
delivered, _pending, _dseries = b4.ty.process_queue(
identifier='cronproj', topdir=repo
)
assert delivered == 1
mock_uts.assert_called_once()
assert mock_uts.call_args.args[1:] == (review_branch, 'thanked')