# -*- coding: utf-8 -*-
import collections
import re
import time
from datetime import datetime, timedelta

import pytest

from utils import seen, Commit, make_basic, REF_PATTERN, MESSAGE_TEMPLATE, validate_all, part_of, to_pr, matches

FMT = '%Y-%m-%d %H:%M:%S'
FAKE_PREV_WEEK = (datetime.now() + timedelta(days=1)).strftime(FMT)

# need:
# * an odoo server
#   - connected to a database
#   - with relevant modules loaded / installed
#   - set up project
#   - add repo, branch(es)
#   - provide visibility to contents si it can be queried & al
# * a tunnel so the server is visible from the outside (webhooks)
# * the ability to create repos on github
#   - repo name
#   - a github user to create a repo with
#   - a github owner to create a repo *for*
#   - provide ability to create commits, branches, prs, ...
def test_straightforward_flow(env, config, make_repo, users):
    # TODO: ~all relevant data in users when creating partners
    # get reviewer's name
    reviewer_name = env['res.partner'].search([
        ('github_login', '=', users['reviewer'])
    ]).name

    prod, other = make_basic(env, config, make_repo)
    other_user = config['role_other']
    other_user_repo = prod.fork(token=other_user['token'])

    b_head = prod.commit('b')
    c_head = prod.commit('c')
    with prod, other_user_repo:
        # create PR as a user with no access to prod (or other)
        [_, p_1] = other_user_repo.make_commits(
            'a',
            Commit('p_0', tree={'x': '0'}),
            Commit('p_1', tree={'x': '1'}),
            ref='heads/hugechange'
        )
        pr = prod.make_pr(
            target='a', title="super important change",
            head=other_user['user'] + ':hugechange',
            token=other_user['token']
        )
        prod.post_status(p_1, 'success', 'legal/cla')
        prod.post_status(p_1, 'success', 'ci/runbot')
        # use rebase-ff (instead of rebase-merge) so we don't have to dig in
        # parents of the merge commit to find the cherrypicks
        pr.post_comment('hansen r+ rebase-ff', config['role_reviewer']['token'])
    pr_id = to_pr(env, pr)
    assert not pr_id.merge_date,\
        "PR obviously shouldn't have a merge date before being merged"

    env.run_crons()
    with prod:
        prod.post_status('staging.a', 'success', 'legal/cla')
        prod.post_status('staging.a', 'success', 'ci/runbot')

    # should merge the staging then create the FP PR
    env.run_crons()

    assert datetime.utcnow() - datetime.strptime(pr_id.merge_date, FMT) <= timedelta(minutes=1),\
        "check if merge date was set about now (within a minute as crons and " \
        "RPC calls yield various delays before we're back)"

    p_1_merged = prod.commit('a')

    assert p_1_merged.id != p_1
    assert p_1_merged.message == MESSAGE_TEMPLATE.format(
        message='p_1',
        repo=prod.name,
        number=pr.number,
        headers='',
        name=reviewer_name,
        email=config['role_reviewer']['email'],
    )
    assert prod.read_tree(p_1_merged) == {
        'f': 'e',
        'x': '1',
    }, "ensure p_1_merged has ~ the same contents as p_1 but is a different commit"
    [p_0_merged] = p_1_merged.parents

    # wait a bit for PR webhook... ?
    time.sleep(5)
    env.run_crons()

    pr0, pr1 = env['runbot_merge.pull_requests'].search([], order='number')
    assert pr0.number == pr.number
    # 50 lines in, we can start checking the forward port...
    assert pr1.parent_id == pr0
    assert pr1.source_id == pr0
    other_owner = other.name.split('/')[0]
    assert re.match(other_owner + ':' + REF_PATTERN.format(target='b', source='hugechange'), pr1.label), \
        "check that FP PR was created in FP target repo"
    c = prod.commit(pr1.head)
    # TODO: add original committer (if !author) as co-author in commit message?
    assert c.author['name'] == other_user['user'], "author should still be original's probably"
    assert c.committer['name'] == other_user['user'], "committer should also still be the original's, really"

    assert pr1.ping == "@%s @%s " % (
        config['role_other']['user'],
        config['role_reviewer']['user'],
    ), "ping of forward-port PR should include author and reviewer of source"

    assert prod.read_tree(c) == {
        'f': 'c',
        'g': 'b',
        'x': '1'
    }
    with prod:
        prod.post_status(pr1.head, 'success', 'ci/runbot')
        prod.post_status(pr1.head, 'success', 'legal/cla')

    env.run_crons()
    env.run_crons('forwardport.reminder', context={'forwardport_updated_before': FAKE_PREV_WEEK})

    pr0_, pr1_, pr2 = env['runbot_merge.pull_requests'].search([], order='number')

    assert pr.comments == [
        (users['reviewer'], 'hansen r+ rebase-ff'),
        seen(env, pr, users),
        (users['user'], 'Merge method set to rebase and fast-forward.'),
    ]
    pr1_remote = prod.get_pr(pr1.number)
    assert pr1_remote.comments == [
        seen(env, pr1_remote, users),
        (users['user'], """\
This PR targets b and is part of the forward-port chain. Further PRs will be created up to c.

More info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port
""")]

    assert pr0_ == pr0
    assert pr1_ == pr1
    assert pr1.parent_id == pr1.source_id == pr0
    assert pr2.parent_id == pr1
    assert pr2.source_id == pr0
    assert not pr0.squash, "original PR has >1 commit"
    assert not (pr1.squash or pr2.squash), "forward ports should also have >1 commit"
    assert re.match(REF_PATTERN.format(target='c', source='hugechange'), pr2.refname), \
        "check that FP PR was created in FP target repo"
    assert prod.read_tree(prod.commit(pr2.head)) == {
        'f': 'c',
        'g': 'a',
        'h': 'a',
        'x': '1'
    }
    pr2_remote = prod.get_pr(pr2.number)
    assert pr2_remote.comments == [
        seen(env, pr2_remote, users),
        (users['user'], """\
@%s @%s this PR targets c and is the last of the forward-port chain containing:
* %s

To merge the full chain, use
> @hansen r+

More info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port
""" % (
            users['other'], users['reviewer'],
            pr1.display_name,
    )),
        (users['user'], "@%s @%s this forward port of %s is awaiting action (not merged or closed)." % (
            users['other'],
            users['reviewer'],
            pr0.display_name,
        ))
    ]
    with prod:
        prod.post_status(pr2.head, 'success', 'ci/runbot')
        prod.post_status(pr2.head, 'success', 'legal/cla')

        pr2_remote.post_comment('hansen r+', config['role_reviewer']['token'])

    env.run_crons()

    assert pr1.staging_id
    assert pr2.staging_id
    # two branches so should have two stagings
    assert pr1.staging_id != pr2.staging_id
    # validate
    with prod:
        prod.post_status('staging.b', 'success', 'ci/runbot')
        prod.post_status('staging.b', 'success', 'legal/cla')
        prod.post_status('staging.c', 'success', 'ci/runbot')
        prod.post_status('staging.c', 'success', 'legal/cla')

    # and trigger merge
    env.run_crons()

    # apparently github strips out trailing newlines when fetching through the
    # API...
    message_template = MESSAGE_TEMPLATE.format(
        message='p_1',
        repo=prod.name,
        number='%s',
        headers='X-original-commit: {}\n'.format(p_1_merged.id),
        name=reviewer_name,
        email=config['role_reviewer']['email'],
    )

    old_b = prod.read_tree(b_head)
    head_b = prod.commit('b')
    assert head_b.message == message_template % pr1.number
    assert prod.commit(head_b.parents[0]).message == part_of(f'p_0\n\nX-original-commit: {p_0_merged}', pr1, separator='\n')
    b_tree = prod.read_tree(head_b)
    assert b_tree == {
        **old_b,
        'x': '1',
    }
    old_c = prod.read_tree(c_head)
    head_c = prod.commit('c')
    assert head_c.message == message_template % pr2.number
    assert prod.commit(head_c.parents[0]).message == part_of(f'p_0\n\nX-original-commit: {p_0_merged}', pr2, separator='\n')
    c_tree = prod.read_tree(head_c)
    assert c_tree == {
        **old_c,
        'x': '1',
    }
    # check that we didn't just smash the original trees
    assert prod.read_tree(prod.commit('a')) != b_tree != c_tree

    prs = env['forwardport.branch_remover'].search([]).mapped('pr_id')
    assert prs == pr0 | pr1 | pr2, "pr1 and pr2 should be slated for branch deletion"
    env.run_crons('forwardport.remover', context={'forwardport_merged_before': FAKE_PREV_WEEK})

    # should not have deleted the base branch (wrong repo)
    assert other_user_repo.get_ref(pr.ref) == p_1

    # should have deleted all PR branches
    pr1_ref = pr1_remote.ref
    with pytest.raises(AssertionError, match='Not Found'):
        other.get_ref(pr1_ref)

    pr2_ref = pr2_remote.ref
    with pytest.raises(AssertionError, match="Not Found"):
        other.get_ref(pr2_ref)

def test_empty(env, config, make_repo, users):
    """ Cherrypick of an already cherrypicked (or separately implemented)
    commit -> conflicting pr.
    """
    prod, other = make_basic(env, config, make_repo, statuses="default")
    # merge change to b
    with prod:
        [p_0] = prod.make_commits(
            'b', Commit('p', tree={'x': '0'}),
            ref='heads/early'
        )
        pr0 = prod.make_pr(target='b', head='early')
        prod.post_status(p_0, 'success')
        pr0.post_comment('hansen r+', config['role_reviewer']['token'])
    env.run_crons()
    with prod:
        prod.post_status('staging.b', 'success')

    # merge same change to a afterwards
    with prod:
        [p_1] = prod.make_commits(
            'a', Commit('p_0', tree={'x': '0'}),
            ref='heads/late'
        )
        pr1 = prod.make_pr(target='a', head='late')
        prod.post_status(p_1, 'success')
        pr1.post_comment('hansen r+', config['role_reviewer']['token'])
    env.run_crons()
    with prod:
        prod.post_status('staging.a', 'success')
    env.run_crons()

    assert prod.read_tree(prod.commit('a')) == {
        'f': 'e',
        'x': '0',
    }
    assert prod.read_tree(prod.commit('b')) == {
        'f': 'c',
        'g': 'b',
        'x': '0',
    }
    # should have 4 PRs:
    # PR 0
    # FP of PR 0 to C
    # PR 1
    # failed FP of PR1 to B
    prs = env['runbot_merge.pull_requests'].search([], order='number')
    assert len(prs) == 4
    pr0_id = prs.filtered(lambda p: p.number == pr0.number)
    pr1_id = prs.filtered(lambda p: p.number == pr1.number)
    fp_id = prs.filtered(lambda p: p.parent_id == pr0_id)
    fail_id = prs - (pr0_id | pr1_id | fp_id)
    assert fp_id
    assert fail_id
    # unlinked from parent since cherrypick failed
    assert not fail_id.parent_id
    # the tree should be clean...
    assert prod.read_tree(prod.commit(fail_id.head)) == {
        'f': 'c',
        'g': 'b',
        'x': '0',
    }

    with prod:
        prod.post_status(fp_id.head, 'success')
        prod.post_status(fail_id.head, 'success')
    env.run_crons()

    # should not have created any new PR
    assert env['runbot_merge.pull_requests'].search([], order='number') == prs
    # change FP token to see if the feedback comes from the proper user
    project = env['runbot_merge.project'].search([])
    project.write({
        'fp_github_name': False,
        'fp_github_token': config['role_other']['token'],
    })
    assert project.fp_github_name == users['other']

    # check reminder
    env.run_crons('forwardport.reminder', context={'forwardport_updated_before': FAKE_PREV_WEEK})
    env.run_crons('forwardport.reminder', context={'forwardport_updated_before': FAKE_PREV_WEEK})

    awaiting = (
        users['other'],
        '@%s @%s this forward port of %s is awaiting action (not merged or closed).' % (
            users['user'], users['reviewer'],
            pr1_id.display_name
        )
    )
    conflict = (users['user'], matches(
        f"""@{users['user']} @{users['reviewer']} cherrypicking of pull request {pr1_id.display_name} failed.

stdout:
```
$$
```

stderr:
```
$$
```

Either perform the forward-port manually (and push to this branch, proceeding as usual) or close this PR (maybe?).

In the former case, you may want to edit this PR message as well.

:warning: after resolving this conflict, you will need to merge it via @{project.github_prefix}.

More info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port
"""))
    assert pr1.comments == [
        (users['reviewer'], 'hansen r+'),
        seen(env, pr1, users),
    ]
    fail_pr = prod.get_pr(fail_id.number)
    assert fail_pr.comments == [
        seen(env, fail_pr, users),
        conflict,
        awaiting,
        awaiting,
    ], "each cron run should trigger a new message"
    # check that this stops if we close the PR
    with prod:
        fail_pr.close()
    env.run_crons('forwardport.reminder', context={'forwardport_updated_before': FAKE_PREV_WEEK})
    assert pr1.comments == [
        (users['reviewer'], 'hansen r+'),
        seen(env, pr1, users),
    ]
    assert fail_pr.comments[2:] == [awaiting]*2,\
        "message should not be triggered on closed PR"

    with prod:
        fail_pr.open()
    with prod:
        prod.post_status(fail_id.head, 'success')
    assert fail_id.state == 'validated'
    env.run_crons('forwardport.reminder', context={'forwardport_updated_before': FAKE_PREV_WEEK})
    assert fail_pr.comments[2:] == [awaiting]*3, "check that message triggers again"

    with prod:
        fail_pr.post_comment('hansen r+', config['role_reviewer']['token'])
    assert fail_id.state == 'ready'
    env.run_crons('forwardport.reminder', context={'forwardport_updated_before': FAKE_PREV_WEEK})
    assert fail_pr.comments[2:] == [
        awaiting,
        awaiting,
        awaiting,
        (users['reviewer'], "hansen r+"),
    ],"if a PR is ready (unblocked), the reminder should not trigger as "\
        "we're likely waiting for the mergebot"

def test_partially_empty(env, config, make_repo):
    """ Check what happens when only some commits of the PR are now empty
    """
    prod, other = make_basic(env, config, make_repo)
    # merge change to b
    with prod:
        [p_0] = prod.make_commits(
            'b', Commit('p', tree={'x': '0'}),
            ref='heads/early'
        )
        pr0 = prod.make_pr(target='b', head='early')
        prod.post_status(p_0, 'success', 'legal/cla')
        prod.post_status(p_0, 'success', 'ci/runbot')
        pr0.post_comment('hansen r+', config['role_reviewer']['token'])
    env.run_crons()
    with prod:
        prod.post_status('staging.b', 'success', 'legal/cla')
        prod.post_status('staging.b', 'success', 'ci/runbot')

    # merge same change to a afterwards
    with prod:
        [*_, p_1] = prod.make_commits(
            'a',
            Commit('p_0', tree={'w': '0'}),
            Commit('p_1', tree={'x': '0'}),
            Commit('p_2', tree={'y': '0'}),
            ref='heads/late'
        )
        pr1 = prod.make_pr(target='a', head='late')
        prod.post_status(p_1, 'success', 'legal/cla')
        prod.post_status(p_1, 'success', 'ci/runbot')
        pr1.post_comment('hansen r+ rebase-merge', config['role_reviewer']['token'])
    env.run_crons()
    with prod:
        prod.post_status('staging.a', 'success', 'legal/cla')
        prod.post_status('staging.a', 'success', 'ci/runbot')

    env.run_crons()
    assert prod.read_tree(prod.commit('a')) == {
        'f': 'e',
        'w': '0',
        'x': '0',
        'y': '0',
    }
    assert prod.read_tree(prod.commit('b')) == {
        'f': 'c',
        'g': 'b',
        'x': '0',
    }

    fail_id = env['runbot_merge.pull_requests'].search([
        ('number', 'not in', [pr0.number, pr1.number]),
        ('parent_id', '=', False),
    ], order='number')
    assert fail_id
    # unlinked from parent since cherrypick failed
    assert not fail_id.parent_id
    # the tree should be clean...
    assert prod.read_tree(prod.commit(fail_id.head)) == {
        'f': 'c',
        'g': 'b',
        'w': '0',
        'x': '0',
        'y': '0',
    }

Case = collections.namedtuple('Case', 'author reviewer delegate success')
ACL = [
    Case('reviewer', 'reviewer', None, True),
    Case('reviewer', 'self_reviewer', None, False),
    Case('reviewer', 'other', None, False),
    Case('reviewer', 'other', 'other', True),

    Case('self_reviewer', 'reviewer', None, True),
    Case('self_reviewer', 'self_reviewer', None, True),
    Case('self_reviewer', 'other', None, False),
    Case('self_reviewer', 'other', 'other', True),

    Case('other', 'reviewer', None, True),
    Case('other', 'self_reviewer', None, False),
    Case('other', 'other', None, True),
    Case('other', 'other', 'other', True),
]
@pytest.mark.parametrize(Case._fields, ACL)
def test_access_rights(env, config, make_repo, users, author, reviewer, delegate, success):
    """Validates the review rights *for the forward-port sequence*, the original
    PR is always reviewed by `user`.
    """
    prod, other = make_basic(env, config, make_repo)
    project = env['runbot_merge.project'].search([])

    # create a partner for `user`
    c = env['res.partner'].create({
        'name': users['user'],
        'github_login': users['user'],
        'email': 'user@example.org',
    })
    c.write({
        'review_rights': [
            (0, 0, {'repository_id': repo.id, 'review': True})
            for repo in project.repo_ids
        ]
    })
    # create a partner for `other` so we can put an email on it
    env['res.partner'].create({
        'name': users['other'],
        'github_login': users['other'],
        'email': 'other@example.org',
    })

    author_token = config['role_' + author]['token']
    fork = prod.fork(token=author_token)
    with prod, fork:
        [c] = fork.make_commits('a', Commit('c_0', tree={'y': '0'}), ref='heads/accessrights')
        pr = prod.make_pr(
            target='a', title='my change',
            head=users[author] + ':accessrights',
            token=author_token,
        )
        prod.post_status(c, 'success', 'legal/cla')
        prod.post_status(c, 'success', 'ci/runbot')
        pr.post_comment('hansen r+', token=config['github']['token'])
        if delegate:
            pr.post_comment('hansen delegate=%s' % users[delegate], token=config['github']['token'])
    env.run_crons()

    with prod:
        prod.post_status('staging.a', 'success', 'legal/cla')
        prod.post_status('staging.a', 'success', 'ci/runbot')
    env.run_crons()

    pr0, pr1 = env['runbot_merge.pull_requests'].search([], order='number')
    assert pr0.state == 'merged'
    with prod:
        prod.post_status(pr1.head, 'success', 'ci/runbot')
        prod.post_status(pr1.head, 'success', 'legal/cla')
    env.run_crons()

    _, _, pr2 = env['runbot_merge.pull_requests'].search([], order='number')
    with prod:
        prod.post_status(pr2.head, 'success', 'ci/runbot')
        prod.post_status(pr2.head, 'success', 'legal/cla')
        prod.get_pr(pr2.number).post_comment(
            'hansen r+',
            token=config['role_' + reviewer]['token']
        )
    env.run_crons()
    if success:
        assert pr1.staging_id and pr2.staging_id,\
            "%s should have approved FP of PRs by %s" % (reviewer, author)
        st = prod.commit('staging.b')
        # Should be signed-off by both original reviewer and forward port reviewer
        original_signoff = signoff(config['role_user'], st.message)
        forward_signoff = signoff(config['role_' + reviewer], st.message)
        assert st.message.index(original_signoff) <= st.message.index(forward_signoff),\
            "Check that FP approver is after original PR approver as that's " \
            "the review path for the PR"
    else:
        assert not (pr1.staging_id or pr2.staging_id),\
            "%s should *not* have approved FP of PRs by %s" % (reviewer, author)
def signoff(conf, message):
    for n in filter(None, [conf.get('name'), conf.get('user')]):
        signoff = 'Signed-off-by: ' + n
        if signoff in message:
            return signoff
    raise AssertionError("Failed to find signoff by %s in %s" % (conf, message))

def test_disapproval(env, config, make_repo, users):
    """The author of a source PR should be able to unapprove the forward port in
    case they approved it then noticed an issue of something.
    """
    # region setup
    prod, _ = make_basic(env, config, make_repo, statuses='default')
    env['res.partner'].create({
        'name': users['other'],
        'github_login': users['other'],
        'email': 'other@example.org',
    })

    author_token = config['role_other']['token']
    fork = prod.fork(token=author_token)
    with prod, fork:
        [c] = fork.make_commits('a', Commit('c_0', tree={'y': '0'}), ref='heads/accessrights')
        pr0 = prod.make_pr(
            target='a', title='my change',
            head=users['other'] + ':accessrights',
            token=author_token,
        )
        prod.post_status(c, 'success')
        pr0.post_comment('hansen r+', token=config['role_reviewer']['token'])
    env.run_crons()

    with prod:
        prod.post_status('staging.a', 'success')
    env.run_crons()

    pr0_id, pr1_id = env['runbot_merge.pull_requests'].search([], order='number')
    assert pr1_id.source_id == pr0_id
    pr1 = prod.get_pr(pr1_id.number)
    assert pr0_id.state == 'merged'
    with prod:
        prod.post_status(pr1_id.head, 'success')
    env.run_crons()
    # endregion

    _, _, pr2_id = env['runbot_merge.pull_requests'].search([], order='number')
    pr2 = prod.get_pr(pr2_id.number)
    with prod:
        prod.post_status(pr2_id.head, 'success')
        pr2.post_comment('hansen r+', token=config['role_other']['token'])
    # no point creating staging for our needs, just propagate statuses
    env.run_crons(None)
    assert pr1_id.state == 'ready'
    assert pr2_id.state == 'ready'

    # oh no, pr1 has an error!
    with prod:
        pr1.post_comment('hansen r-', token=config['role_other']['token'])
    env.run_crons(None)
    assert pr1_id.state == 'validated', "pr1 should not be approved anymore"
    assert pr2_id.state == 'ready', "pr2 should not be affected"

    assert pr1.comments == [
        seen(env, pr1, users),
        (users['user'], 'This PR targets b and is part of the forward-port chain. Further PRs will be created up to c.\n\nMore info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port\n'),
        (users['other'], "hansen r-"),
        (users['user'], "Note that only this forward-port has been unapproved, "
                        "sibling forward ports may have to be unapproved "
                        "individually."),
    ]
    with prod:
        pr2.post_comment('hansen r-', token=config['role_other']['token'])
    env.run_crons(None)
    assert pr2_id.state == 'validated'
    assert pr2.comments[2:] == [
        (users['other'], "hansen r+"),
        (users['other'], "hansen r-"),
    ], "shouldn't have a warning on r- because it's the only approved PR of the chain"

def test_delegate_fw(env, config, make_repo, users):
    """If a user is delegated *on a forward port* they should be able to approve
    *the followup*.
    """
    prod, _ = make_basic(env, config, make_repo)
    # create a partner for `other` so we can put an email on it
    env['res.partner'].create({
        'name': users['other'],
        'github_login': users['other'],
        'email': 'other@example.org',
    })
    author_token = config['role_self_reviewer']['token']
    fork = prod.fork(token=author_token)
    with prod, fork:
        [c] = fork.make_commits('a', Commit('c_0', tree={'y': '0'}), ref='heads/accessrights')
        pr = prod.make_pr(
            target='a', title='my change',
            head=users['self_reviewer'] + ':accessrights',
            token=author_token,
        )
        prod.post_status(c, 'success', 'legal/cla')
        prod.post_status(c, 'success', 'ci/runbot')
        pr.post_comment('hansen r+', token=config['role_reviewer']['token'])
    env.run_crons()

    with prod:
        prod.post_status('staging.a', 'success', 'legal/cla')
        prod.post_status('staging.a', 'success', 'ci/runbot')
    env.run_crons()

    # ensure pr1 has to be approved to be forward-ported
    _, pr1_id = env['runbot_merge.pull_requests'].search([], order='number')
    # detatch from source
    pr1_id.write({
        'parent_id': False,
        'detach_reason': "Detached for testing.",
    })
    with prod:
        prod.post_status(pr1_id.head, 'success', 'legal/cla')
        prod.post_status(pr1_id.head, 'success', 'ci/runbot')
    env.run_crons()
    pr1 = prod.get_pr(pr1_id.number)
    # delegate review to "other" consider PR fixed, and have "other" approve it
    with prod:
        pr1.post_comment('hansen delegate=' + users['other'],
                         token=config['role_reviewer']['token'])
        prod.post_status(pr1_id.head, 'success', 'ci/runbot')
        pr1.post_comment('hansen r+', token=config['role_other']['token'])
    env.run_crons()

    with prod:
        prod.post_status('staging.b', 'success', 'legal/cla')
        prod.post_status('staging.b', 'success', 'ci/runbot')
    env.run_crons()

    _, _, pr2_id = env['runbot_merge.pull_requests'].search([], order='number')
    pr2 = prod.get_pr(pr2_id.number)
    # make "other" also approve this one
    with prod:
        prod.post_status(pr2_id.head, 'success', 'ci/runbot')
        prod.post_status(pr2_id.head, 'success', 'legal/cla')
        pr2.post_comment('hansen r+', token=config['role_other']['token'])
    env.run_crons()

    assert pr2.comments == [
        seen(env, pr2, users),
        (users['user'], '''@{self_reviewer} @{reviewer} this PR targets c and is the last of the forward-port chain.

To merge the full chain, use
> @hansen r+

More info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port
'''.format_map(users)),
        (users['other'], 'hansen r+')
    ]
    assert pr2_id.reviewed_by


def test_redundant_approval(env, config, make_repo, users):
    """If a forward port sequence has been partially approved, fw-bot r+ should
    not perform redundant approval as that triggers warning messages.
    """
    prod, _ = make_basic(env, config, make_repo)
    [project] = env['runbot_merge.project'].search([])
    with prod:
        prod.make_commits(
            'a', Commit('p', tree={'x': '0'}),
            ref='heads/early'
        )
        pr0 = prod.make_pr(target='a', head='early')
        prod.post_status('heads/early', 'success', 'legal/cla')
        prod.post_status('heads/early', 'success', 'ci/runbot')
        pr0.post_comment('hansen r+', config['role_reviewer']['token'])
    env.run_crons()
    with prod:
        prod.post_status('staging.a', 'success', 'legal/cla')
        prod.post_status('staging.a', 'success', 'ci/runbot')
    env.run_crons()
    pr0_id, pr1_id = env['runbot_merge.pull_requests'].search([], order='number asc')
    with prod:
        prod.post_status(pr1_id.head, 'success', 'legal/cla')
        prod.post_status(pr1_id.head, 'success', 'ci/runbot')
    env.run_crons()

    _, _, pr2_id = env['runbot_merge.pull_requests'].search([], order='number asc')
    assert pr2_id.parent_id == pr1_id
    assert pr1_id.parent_id == pr0_id

    pr1 = prod.get_pr(pr1_id.number)
    pr2 = prod.get_pr(pr2_id.number)
    with prod:
        pr1.post_comment('hansen r+', config['role_reviewer']['token'])
    with prod:
        pr2.post_comment('hansen r+', config['role_reviewer']['token'])
    env.run_crons()

    assert pr1.comments == [
        seen(env, pr1, users),
        (users['user'], 'This PR targets b and is part of the forward-port chain. '
                        'Further PRs will be created up to c.\n\n'
                        'More info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port\n'),
        (users['reviewer'], 'hansen r+'),
    ]


def test_batched(env, config, make_repo, users):
    """ Tests for projects with multiple repos & sync'd branches. Batches
    should be FP'd to batches
    """
    main1, _ = make_basic(env, config, make_repo, reponame='main1')
    main2, _ = make_basic(env, config, make_repo, reponame='main2')
    main1.unsubscribe(config['role_reviewer']['token'])
    main2.unsubscribe(config['role_reviewer']['token'])

    friendo = config['role_other']
    other1 = main1.fork(token=friendo['token'])
    other2 = main2.fork(token=friendo['token'])

    with main1, other1:
        [c1] = other1.make_commits(
            'a', Commit('commit repo 1', tree={'1': 'a'}),
            ref='heads/contribution'
        )
        pr1 = main1.make_pr(
            target='a', title="My contribution",
            head=friendo['user'] + ':contribution',
            token=friendo['token']
        )
        # we can ack it directly as it should not be taken in account until
        # we run crons
        validate_all([main1], [c1])
        pr1.post_comment('hansen r+', config['role_reviewer']['token'])
    with main2, other2:
        [c2] = other2.make_commits(
            'a', Commit('commit repo 2', tree={'2': 'a'}),
            ref='heads/contribution' # use same ref / label as pr1
        )
        pr2 = main2.make_pr(
            target='a', title="Main2 part of my contribution",
            head=friendo['user'] + ':contribution',
            token=friendo['token']
        )
        validate_all([main2], [c2])
        pr2.post_comment('hansen r+', config['role_reviewer']['token'])

    env.run_crons()

    # sanity check: this should have created a staging with 1 batch with pr1 and pr2
    stagings = env['runbot_merge.stagings'].search([])
    assert len(stagings) == 1
    assert stagings.target.name == 'a'
    assert len(stagings.batch_ids) == 1
    assert stagings.mapped('batch_ids.prs.number') == [pr1.number, pr2.number]

    with main1, main2:
        validate_all([main1, main2], ['staging.a'])
    env.run_crons()

    PullRequests = env['runbot_merge.pull_requests']
    # created the first forward port, need to validate it so the second one is
    # triggered (FP only goes forward on CI+) (?)
    pr1b = PullRequests.search([
        ('source_id', '!=', False),
        ('repository.name', '=', main1.name),
    ])
    pr2b = PullRequests.search([
        ('source_id', '!=', False),
        ('repository.name', '=', main2.name),
    ])
    # check that relevant users were pinged
    ping = (users['user'], """\
This PR targets b and is part of the forward-port chain. Further PRs will be created up to c.

More info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port
""")
    pr_remote_1b = main1.get_pr(pr1b.number)
    pr_remote_2b = main2.get_pr(pr2b.number)
    assert pr_remote_1b.comments == [seen(env, pr_remote_1b, users), ping]
    assert pr_remote_2b.comments == [seen(env, pr_remote_2b, users), ping]

    with main1, main2:
        validate_all([main1], [pr1b.head])
        validate_all([main2], [pr2b.head])
    env.run_crons() # process updated statuses -> generate followup FP

    # should have created two PRs whose source is p1 and two whose source is p2
    pr1a, pr1b, pr1c = PullRequests.search([
        ('repository.name', '=', main1.name),
    ], order='number')
    pr2a, pr2b, pr2c = PullRequests.search([
        ('repository.name', '=', main2.name),
    ], order='number')

    assert pr1a.number == pr1.number
    assert pr2a.number == pr2.number
    assert pr1a.state == pr2a.state == 'merged'

    assert pr1b.label == pr2b.label, "batched source should yield batched FP"
    assert pr1c.label == pr2c.label, "batched source should yield batched FP"
    assert pr1b.label != pr1c.label

    project = env['runbot_merge.project'].search([])
    # ok main1 PRs
    with main1:
        validate_all([main1], [pr1c.head])
        main1.get_pr(pr1c.number).post_comment('hansen r+', config['role_reviewer']['token'])
    env.run_crons()

    # check that the main1 PRs are ready but blocked on the main2 PRs
    assert pr1b.state == 'ready'
    assert pr1c.state == 'ready'
    assert pr1b.blocked
    assert pr1c.blocked

    # ok main2 PRs
    with main2:
        validate_all([main2], [pr2c.head])
        main2.get_pr(pr2c.number).post_comment('hansen r+', config['role_reviewer']['token'])
    env.run_crons()

    env['runbot_merge.stagings'].search([]).mapped('target.display_name')
    env['runbot_merge.stagings'].search([], order='target').mapped('target.display_name')
    stc, stb = env['runbot_merge.stagings'].search([], order='target')
    assert stb.target.name == 'b'
    assert stc.target.name == 'c'

    with main1, main2:
        validate_all([main1, main2], ['staging.b', 'staging.c'])

class TestClosing:
    def test_closing_before_fp(self, env, config, make_repo, users):
        """ Closing a PR should preclude its forward port
        """
        prod, other = make_basic(env, config, make_repo)
        with prod:
            [p_1] = prod.make_commits(
                'a',
                Commit('p_0', tree={'x': '0'}),
                ref='heads/hugechange'
            )
            pr = prod.make_pr(target='a', head='hugechange')
            prod.post_status(p_1, 'success', 'legal/cla')
            prod.post_status(p_1, 'success', 'ci/runbot')
            pr.post_comment('hansen r+', config['role_reviewer']['token'])

        env.run_crons()
        with prod:
            prod.post_status('staging.a', 'success', 'legal/cla')
            prod.post_status('staging.a', 'success', 'ci/runbot')
        # should merge the staging then create the FP PR
        env.run_crons()

        pr0_id, pr1_id = env['runbot_merge.pull_requests'].search([], order='number')
        # close the FP PR then have CI validate it
        pr1 = prod.get_pr(pr1_id.number)
        with prod:
            pr1.close()
        assert pr1_id.state == 'closed'
        assert not pr1_id.parent_id, "closed PR should should be detached from its parent"
        with prod:
            prod.post_status(pr1_id.head, 'success', 'legal/cla')
            prod.post_status(pr1_id.head, 'success', 'ci/runbot')
        env.run_crons()
        env.run_crons('forwardport.reminder')

        assert env['runbot_merge.pull_requests'].search([], order='number') == pr0_id | pr1_id,\
            "closing the PR should suppress the FP sequence"
        assert pr1.comments == [
            seen(env, pr1, users),
            (users['user'], """\
This PR targets b and is part of the forward-port chain. Further PRs will be created up to c.

More info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port
""")
        ]

    def test_closing_after_fp(self, env, config, make_repo, users):
        """ Closing a PR which has been forward-ported should not touch the
        followups
        """
        prod, other = make_basic(env, config, make_repo)
        project = env['runbot_merge.project'].search([])
        with prod:
            [p_1] = prod.make_commits(
                'a',
                Commit('p_0', tree={'x': '0'}),
                ref='heads/hugechange'
            )
            pr = prod.make_pr(target='a', head='hugechange')
            prod.post_status(p_1, 'success', 'legal/cla')
            prod.post_status(p_1, 'success', 'ci/runbot')
            pr.post_comment('hansen r+', config['role_reviewer']['token'])

        env.run_crons()
        with prod:
            prod.post_status('staging.a', 'success', 'legal/cla')
            prod.post_status('staging.a', 'success', 'ci/runbot')

        # should merge the staging then create the FP PR
        env.run_crons()

        pr0_id, pr1_id = env['runbot_merge.pull_requests'].search([], order='number')
        with prod:
            prod.post_status(pr1_id.head, 'success', 'legal/cla')
            prod.post_status(pr1_id.head, 'success', 'ci/runbot')
        # should create the second staging
        env.run_crons()

        pr0_id2, pr1_id2, pr2_id = env['runbot_merge.pull_requests'].search([], order='number')
        assert pr0_id2 == pr0_id
        assert pr1_id2 == pr1_id

        pr1 = prod.get_pr(pr1_id.number)
        with prod:
            pr1.close()

        assert pr1_id.state == 'closed'
        assert not pr1_id.parent_id
        assert pr2_id.state == 'opened'
        assert not pr2_id.parent_id, \
            "the descendant of a closed PR doesn't really make sense, maybe?"

        with prod:
            pr1.open()
        assert pr1_id.state == 'validated'
        assert not pr1_id.parent_id
        assert not pr2_id.parent_id

    def test_close_disabled(self, env, make_repo, users, config):
        """ If an fwport's target is disabled and its branch is closed, it
        should not be notified (multiple times), also its descendant should not
        be nodified if already merged, also there should not be recursive
        notifications (odoo/odoo#145969, odoo/odoo#145984)
        """
        repo, _ = make_basic(env, config, make_repo)
        env['runbot_merge.repository'].search([]).required_statuses = 'default'
        # prep: merge PR, create two forward ports
        with repo:
            [c1] = repo.make_commits('a', Commit('first', tree={'m': 'c1'}))
            pr1 = repo.make_pr(title='title', body='body', target='a', head=c1)
            pr1.post_comment('hansen r+', config['role_reviewer']['token'])
            repo.post_status(c1, 'success')
        env.run_crons()

        pr1_id = to_pr(env, pr1)
        assert pr1_id.state == 'ready', pr1_id.blocked

        with repo:
            repo.post_status('staging.a', 'success')
        env.run_crons()

        pr1_id_, pr2_id = env['runbot_merge.pull_requests'].search([], order='number')
        assert pr1_id_ == pr1_id
        with repo:
            repo.post_status(pr2_id.head, 'success')
        env.run_crons()

        _, _, pr3_id = env['runbot_merge.pull_requests'].search([], order='number')

        # disable second branch
        pr2_id.target.active = False
        env.run_crons()

        pr2 = repo.get_pr(pr2_id.number)
        assert pr2.comments == [
            seen(env, pr2, users),
            (users['user'], "This PR targets b and is part of the forward-port chain. "
                           "Further PRs will be created up to c.\n\n"
                           "More info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port\n"),
            (users['user'], "@{user} @{reviewer} the target branch 'b' has been disabled, you may want to close this PR.".format_map(
                users
            )),
        ]
        pr3 = repo.get_pr(pr3_id.number)
        assert pr3.comments == [
            seen(env, pr3, users),
            (users['user'], """\
@{user} @{reviewer} this PR targets c and is the last of the forward-port chain containing:
* {pr2_id.display_name}

To merge the full chain, use
> @hansen r+

More info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port
""".format(pr2_id=pr2_id, **users)),
        ]

        # some time later, notice PR3 is open and merge it
        with repo:
            pr3.post_comment('hansen r+', config['role_reviewer']['token'])
            repo.post_status(pr3.head, 'success')
        env.run_crons()
        with repo:
            repo.post_status('staging.c', 'success')
        env.run_crons()

        assert pr3_id.status == 'success'

        # even later, notice PR2 is still open but not mergeable anymore
        with repo:
            pr2.close()
        env.run_crons()

        assert pr2.comments[3:] == []
        assert pr3.comments[2:] == [(users['reviewer'], "hansen r+")]

class TestBranchDeletion:
    def test_delete_normal(self, env, config, make_repo):
        """ Regular PRs should get their branch deleted as long as they're
        created in the fp repository
        """
        prod, other = make_basic(env, config, make_repo)
        with prod, other:
            [c] = other.make_commits('a', Commit('c', tree={'0': '0'}), ref='heads/abranch')
            pr = prod.make_pr(
                target='a', head='%s:abranch' % other.owner,
                title="a pr",
            )
            prod.post_status(c, 'success', 'legal/cla')
            prod.post_status(c, 'success', 'ci/runbot')
            pr.post_comment('hansen r+', config['role_reviewer']['token'])
        env.run_crons()

        with prod:
            prod.post_status('staging.a', 'success', 'legal/cla')
            prod.post_status('staging.a', 'success', 'ci/runbot')
        env.run_crons()

        pr_id = to_pr(env, pr)
        assert pr_id.state == 'merged'
        removers = env['forwardport.branch_remover'].search([])
        to_delete_branch = removers.mapped('pr_id')
        assert to_delete_branch == pr_id

        env.run_crons('forwardport.remover', context={'forwardport_merged_before': FAKE_PREV_WEEK})
        with pytest.raises(AssertionError, match="Not Found"):
            other.get_ref('heads/abranch')

    def test_not_merged(self, env, config, make_repo):
        """ The branches of PRs which are still open or have been closed (rather
        than merged) should not get deleted
        """
        prod, other = make_basic(env, config, make_repo)
        with prod, other:
            [c] = other.make_commits('a', Commit('c1', tree={'1': '0'}), ref='heads/abranch')
            pr1 = prod.make_pr(target='a', head='%s:abranch' % other.owner, title='a')
            prod.post_status(c, 'success', 'legal/cla')
            prod.post_status(c, 'success', 'ci/runbot')
            pr1.post_comment('hansen r+', config['role_reviewer']['token'])

            other.make_commits('a', Commit('c2', tree={'2': '0'}), ref='heads/bbranch')
            pr2 = prod.make_pr(target='a', head='%s:bbranch' % other.owner, title='b')
            pr2.close()

            [c] = other.make_commits('a', Commit('c3', tree={'3': '0'}), ref='heads/cbranch')
            pr3 = prod.make_pr(target='a', head='%s:cbranch' % other.owner, title='c')
            prod.post_status(c, 'success', 'legal/cla')
            prod.post_status(c, 'success', 'ci/runbot')

            other.make_commits('a', Commit('c3', tree={'4': '0'}), ref='heads/dbranch')
            pr4 = prod.make_pr(target='a', head='%s:dbranch' % other.owner, title='d')
            pr4.post_comment('hansen r+', config['role_reviewer']['token'])
        env.run_crons()

        PR = env['runbot_merge.pull_requests']
        # check PRs are in states we expect
        pr_heads = []
        for p, st in [(pr1, 'ready'), (pr2, 'closed'), (pr3, 'validated'), (pr4, 'approved')]:
            p_id = PR.search([
                ('repository.name', '=', prod.name),
                ('number', '=', p.number),
            ])
            assert p_id.state == st
            pr_heads.append(p_id.head)

        env.run_crons('forwardport.remover', context={'forwardport_merged_before': FAKE_PREV_WEEK})

        # check that the branches still exist
        assert other.get_ref('heads/abranch') == pr_heads[0]
        assert other.get_ref('heads/bbranch') == pr_heads[1]
        assert other.get_ref('heads/cbranch') == pr_heads[2]
        assert other.get_ref('heads/dbranch') == pr_heads[3]

def sPeNgBaB(s):
    return ''.join(
        l if i % 2 == 0 else l.upper()
        for i, l in enumerate(s)
    )
def test_spengbab():
    assert sPeNgBaB("spongebob") == 'sPoNgEbOb'

class TestRecognizeCommands:
    def make_pr(self, env, config, make_repo):
        r, _ = make_basic(env, config, make_repo)

        with r:
            r.make_commits('c', Commit('p', tree={'x': '0'}), ref='heads/testbranch')
            pr = r.make_pr(target='a', head='testbranch')

        return r, pr, to_pr(env, pr)

    # FIXME: remove / merge into mergebot tests
    def test_botname_casing(self, env, config, make_repo):
        """ Test that the botname is case-insensitive as people might write
        bot names capitalised or titlecased or uppercased or whatever
        """
        repo, pr, pr_id = self.make_pr(env, config, make_repo)
        assert pr_id.state == 'opened'
        [a] = env['runbot_merge.branch'].search([
            ('name', '=', 'a')
        ])

        names = [
            "hansen",
            "HANSEN",
            "Hansen",
            sPeNgBaB("hansen"),
        ]

        for n in names:
            assert not pr_id.limit_id
            with repo:
                pr.post_comment(f'@{n} up to a', config['role_reviewer']['token'])
            assert pr_id.limit_id == a
            # reset state
            pr_id.limit_id = False

    # FIXME: remove / merge into mergebot tests
    @pytest.mark.parametrize('indent', ['', '\N{SPACE}', '\N{SPACE}'*4, '\N{TAB}'])
    def test_botname_indented(self, env, config, make_repo, indent):
        """ matching botname should ignore leading whitespaces
        """
        repo, pr, pr_id = self.make_pr(env, config, make_repo)
        assert pr_id.state == 'opened'
        [a] = env['runbot_merge.branch'].search([
            ('name', '=', 'a')
        ])

        assert not pr_id.limit_id
        with repo:
            pr.post_comment(f'{indent}@hansen up to a', config['role_reviewer']['token'])
        assert pr_id.limit_id == a
