.+)\)
+ # promisor filters
+ (?: \[.*])?
+ ''',
+ line,
+ re.VERBOSE,
+ ):
+ yield typing.cast(tuple[str, str, str], m.groups())
+
+
+def from_remotes() -> str:
+ for _name, url, _kind in remotes():
+ for prefix in ('git@github.com:', 'https://github.com/'):
+ if url.startswith(prefix):
+ return url.removeprefix(prefix)\
+ .removesuffix('.git')\
+ .replace('odoo-dev/', 'odoo/')
+ exit(f"Found no github remote in:\n{r.stdout}")
+
+
def ls_remote(
repository: str, *patterns: str
) -> tuple[str, typing.Iterator[tuple[str, str]]]:
@@ -151,7 +206,7 @@ def ls_remote(
f"git@github.com:{repository}",
]:
r = run(
- ["git", "ls-remote", "-q", repository_url, *patterns],
+ ["git", "-c", "credential.interactive=false", "ls-remote", "-q", repository_url, *patterns],
stdout=PIPE,
stderr=DEVNULL,
)
@@ -178,5 +233,17 @@ def info(url: str) -> PrInfo:
exit(f"invalid response (not json): {e}")
+def check_version(f):
+ if not f:
+ return
+
+ h = hashlib.blake2b(pathlib.Path(f).read_bytes(), usedforsecurity=False)
+ with urllib.request.urlopen(SCRIPT_URL) as r:
+ hh = hashlib.blake2b(r.read(), usedforsecurity=False)
+ if h.digest() != hh.digest():
+ print(f"You may want to update {f}:\nA different version is available at {SCRIPT_URL}")
+
+
if __name__ == "__main__":
+ threading.Thread(target=check_version, args=(__file__,), daemon=True).start()
exit(main())
diff --git a/runbot_merge/static/src/js/runbot_merge.js b/runbot_merge/static/src/js/runbot_merge.js
index df9095a8d..642606674 100644
--- a/runbot_merge/static/src/js/runbot_merge.js
+++ b/runbot_merge/static/src/js/runbot_merge.js
@@ -46,38 +46,28 @@ if (document.readyState === "loading") {
/* region cross-staging batch highlighting */
window.addEventListener("mouseover", (e) => {
- const batch = e.target.closest('li.batch');
+ const batch = e.target.closest('[data-batch-id]');
if (!batch) return;
// Only trigger if coming from outside this batch
const related = e.relatedTarget;
if (!related || !batch.contains(related)) {
- for (const b of document.querySelectorAll(`li.batch[data-batch-id="${batch.dataset.batchId}"]`)) {
- b.style.outline = '1px dashed var(--body-color)';
+ for (const b of document.querySelectorAll(`[data-batch-id="${batch.dataset.batchId}"]`)) {
+ b.style.outline = 'thin dashed';
}
}
});
window.addEventListener("mouseout", (e) => {
- const batch = e.target.closest('li.batch');
+ const batch = e.target.closest('[data-batch-id]');
if (!batch) return;
// Only trigger if leaving to outside this batch
const related = e.relatedTarget;
if (!related || !batch.contains(related)) {
- for (const b of document.querySelectorAll(`li.batch[data-batch-id="${batch.dataset.batchId}"]`)) {
+ for (const b of document.querySelectorAll(`[data-batch-id="${batch.dataset.batchId}"]`)) {
b.style.outline = '';
}
}
});
/* endregion */
-
-/* region dropdowns */
-// If there's an open dropdown and we click outside the dropdown, close the dropdown.
-window.addEventListener("click", e => {
- const dropdown = document.querySelector('details[name="dropdown"][open]');
- if (dropdown && !dropdown.contains(e.target)) {
- dropdown.removeAttribute('open');
- }
-});
-
window.addEventListener("click", e => {
const toggle = e.target.closest('a.dropdown-toggle');
if (toggle) {
@@ -96,61 +86,10 @@ window.addEventListener("click", e => {
title.classList.toggle('fold');
}
});
-
-/**
- * Only implement flipping up if there's no space below, not the left/right
- * toggling and sliding.
- *
- * TODO: use popper.js instead to get more flexible behaviour?
- */
-function placeDropdown(details) {
- const viewportHeight = document.documentElement.clientHeight;
-
- const detailsRect = details.getBoundingClientRect();
- const dropDown = details.querySelector(':scope > div');
- const dropdownRect = dropDown.getBoundingClientRect()
-
- // Amount of clipping in each direction (negative if dropdown is fully inside the viewport)
- const clippingBottom = (detailsRect.bottom + dropdownRect.height) - viewportHeight;
- // fastpath
- if (clippingBottom <= 0 && !dropDown.style.inset) {
- return;
- }
-
- const clippingTop = -(detailsRect.top - dropdownRect.height);
- let inset;
- if (clippingBottom <= 0 || clippingBottom <= clippingTop) {
- inset = `${detailsRect.height}px auto auto 0`;
- } else {
- inset = `auto auto ${detailsRect.height}px 0`;
- }
- dropDown.style.inset = inset;
-}
-
-window.addEventListener("click", e => {
- const summary = e.target.closest('summary');
- const details = summary?.parentNode;
- if (details && !details.hasAttribute('open') && details.getAttribute('name') === 'dropdown') {
- summary.nextElementSibling.style.visibility = 'hidden';
- }
-});
-window.addEventListener("toggle", e => {
- if (e.newState === 'open' && e.target.matches('details[name="dropdown"]')) {
- placeDropdown(e.target);
- }
- e.target.querySelector(':scope>div').style.visibility = '';
-}, {capture: true});
-
-window.addEventListener('scroll', _ => {
- const openDetails = document.querySelector('details[name="dropdown"][open]');
- if (openDetails) {
- placeDropdown(openDetails);
- }
-});
-window.addEventListener('resize', _ => {
- const openDetails = document.querySelector('details[name="dropdown"][open]');
- if (openDetails) {
- placeDropdown(openDetails);
+window.addEventListener('touchstart', e => {
+ const title = e.target.tagName !== 'A' && e.target.closest('section>section>h2');
+ if (title) {
+ title.classList.toggle('fold');
+ return false;
}
});
-/* endregion */
\ No newline at end of file
diff --git a/runbot_merge/static/src/runbot_merge.css b/runbot_merge/static/src/runbot_merge.css
index 47cfd6bef..8412992b1 100644
--- a/runbot_merge/static/src/runbot_merge.css
+++ b/runbot_merge/static/src/runbot_merge.css
@@ -436,13 +436,7 @@ ul.stagings {
grid-template-columns: repeat(12, minmax( min(11pc + var(--pad1) + var(--pad2), 49vw - var(--gutter-x) * 0.5), 1fr));
grid-template-rows: repeat(2, auto);
grid-column-gap: 1px;
- /*
- This is Not Awesome, but apparently you can not combine auto in one
- direction and visible in the other, so you can't have the dropdown
- get out of the stagings at the bottom while having the stagings
- horizontally scrollable (at least not without JS scrolling).
- */
- overflow-x: clip;
+ overflow-x: auto;
&>li.staging {
grid-template-rows: subgrid;
@@ -472,11 +466,17 @@ ul.stagings {
}
}
}
- &>details {
- margin: 0.5em 0;
- &>summary {
- text-indent: 1em hanging;
- }
+ /*
+ :open is supposed to work on popovertarget (in supporting browser)
+ but it doesn't seem to work in FF while matching
+ `@supports selector(:has)`, so FF gets neither version therefore
+ just use the legacy one
+ */
+ &>button[popovertarget]:before {
+ content: '▸ ';
+ }
+ &:has([popover]:popover-open) > button[popovertarget]:before {
+ content: "▾ ";
}
}
}
@@ -492,38 +492,48 @@ ul.stagings {
}
}
-details[name="dropdown"] {
- position: relative;
- &>summary {
- padding: 0 var(--pad1, 0) 0 var(--pad2, 0);
+div.dropdown[popover] {
+ :has(&) {
+ anchor-scope: all;
+ }
+ button:has(+ &) {
+ display: inline-block;
+ background: none;
+ border: none;
cursor: pointer;
user-select: none;
+
+ text-align: start;
+ text-indent: 0.8em hanging;
+ margin: 0.5em 0;
+ padding: 0 var(--pad1, 0) 0 var(--pad2, 0);
}
- &>div {
- position: absolute;
- z-index: 100;
- max-height: 70vh;
- min-width: 10rem;
- overflow: auto;
- color: var(--color-foreground);
- background-color: var(--color-background);
+ position: absolute;
+ inset: unset;
+ top: anchor(var(--my-anchor) bottom);
+ left: anchor(var(--my-anchor) left);
+ position-try-fallbacks: flip-block, flip-inline;
- padding: 0.5rem 0;
- border: 1px solid transparent;
- border-radius: 0.25rem;
+ max-height: 70vh;
+ min-width: 10rem;
+ overflow: auto;
- /*inset: 100% 0 auto auto;*/
+ color: var(--color-foreground);
+ background-color: var(--color-background);
- & > * {
- display: block;
- width: 100%;
- padding: 0.25rem 1rem;
- white-space: nowrap;
- color: color-mix(in srgb, var(--link-color) 85%, var(--color-background));
- &:hover {
- color: color-mix(in srgb, var(--link-color), var(--color-foreground));
- }
+ padding: 0.5rem 0;
+ border: 1px solid transparent;
+ border-radius: 0.25rem;
+
+ & > * {
+ display: block;
+ width: 100%;
+ padding: 0.25rem 1rem;
+ white-space: nowrap;
+ color: color-mix(in srgb, var(--link-color) 85%, var(--color-background));
+ &:hover {
+ color: color-mix(in srgb, var(--link-color), var(--color-foreground));
}
}
}
diff --git a/runbot_merge/tests/test_basic.py b/runbot_merge/tests/test_basic.py
index 11efec797..4410f30cc 100644
--- a/runbot_merge/tests/test_basic.py
+++ b/runbot_merge/tests/test_basic.py
@@ -158,7 +158,7 @@ def test_trivial_flow(env, repo, page, users, config, project, partners, status_
}
p = html.fromstring(page('/runbot_merge'))
- s = p.cssselect('.staging details[name="dropdown"] a')
+ s = p.cssselect('.staging div.staging-statuses a')
assert len(s) == 2, "not logged so only *required* statuses"
for e, status in zip(s, ['legal/cla', 'ci/runbot']):
assert set(e.classes) == {'bg-success'}
@@ -740,13 +740,13 @@ def test_ff_failure(env, repo, config, page):
repo.post_status('staging.master', 'success')
env.run_crons()
- assert st.reason == 'update is not a fast forward'
+ assert st.reason == 'rejected (non-fast-forward)'
# check that it's added as title on the staging
doc = html.fromstring(page('/runbot_merge'))
_new, prev = doc.cssselect('li.staging')
assert 'bg-gray-lighter' in prev.classes, "ff failure is ~ cancelling"
- assert 'fast forward failed (update is not a fast forward)' in prev.get('title')
+ assert 'fast forward failed (rejected (non-fast-forward))' in prev.get('title')
assert to_pr(env, prx).staging_id, "merge should not have succeeded"
assert repo.commit('staging.master').id != staging.id,\
@@ -1448,8 +1448,12 @@ def test_pr_no_method(self, repo, env, users, config):
Commit('B1', tree={'b': '1'}),
)
prx = repo.make_pr(title='title', body='body', target='master', head=b1)
- repo.post_status(prx.head, 'success')
prx.post_comment('hansen r+', config['role_reviewer']['token'])
+ # wait hook and run crons before status to works around a race in
+ # runbot mode (with high parallelism)
+ env.run_crons()
+ with repo:
+ repo.post_status(prx.head, 'success')
env.run_crons()
assert not to_pr(env, prx).staging_id
@@ -1464,7 +1468,6 @@ def test_pr_no_method(self, repo, env, users, config):
* `rebase-merge` to rebase and merge, using the PR as merge commit message
* `rebase-ff` to rebase and fast-forward
""".format_map(users)),
- (users['user'], "@{user} @{reviewer} unable to stage: missing merge method".format_map(users)),
]
def test_pr_method_no_review(self, repo, env, users, config):
@@ -2581,6 +2584,7 @@ def test_update_incorrect_commits_count(self, port, env, project, repo, config,
with repo:
pr.post_comment("hansen r+", config['role_reviewer']['token'])
+ with repo:
repo.post_status(c, 'success')
env.run_crons()
assert not pr_id.blocked
diff --git a/runbot_merge/tests/test_batching.py b/runbot_merge/tests/test_batching.py
index 0bf4ed061..ab9c0713f 100644
--- a/runbot_merge/tests/test_batching.py
+++ b/runbot_merge/tests/test_batching.py
@@ -581,7 +581,7 @@ def test_not_prestage(env, project, repo, users, config):
# pytest assertion rewriting tacks the extra info to the user-provided
# assertion message, so we need to strip it
payload, _ = e.value.args[0].split('\n', 1)
- assert json.loads(payload)['status'] == '404'
+ assert json.loads(payload)['status'] == '422'
def test_split_depthfirst(env, project, repo, users, config):
project.branch_ids.depth_first_splits = True
@@ -656,4 +656,17 @@ def test_solve_case(env, project, repo, users, config, on_fail, cutoff):
# been picked up, with PR 5+~6 being overflow
assert pr_ids[0].error
assert all([p.staging_id for p in pr_ids[1:cutoff]])
- assert all([not p.staging_id for p in pr_ids[cutoff:]])
\ No newline at end of file
+ assert all([not p.staging_id for p in pr_ids[cutoff:]])
+
+def test_join_last(env, project, repo, users, config):
+ project.branch_ids.on_fail = 'join'
+ with repo:
+ repo.make_commits(None, Commit('x', tree={'a': 'a'}), ref='heads/master')
+ pr = _pr(repo, 'y', [{'c': '1'}], user=config['role_user']['token'], reviewer=config['role_reviewer']['token'])
+ env.run_crons()
+
+ with repo:
+ repo.post_status('staging.master', 'failure')
+ env.run_crons()
+
+ assert to_pr(env, pr).error
\ No newline at end of file
diff --git a/runbot_merge/tests/test_oddities.py b/runbot_merge/tests/test_oddities.py
index 67905346f..d5d871441 100644
--- a/runbot_merge/tests/test_oddities.py
+++ b/runbot_merge/tests/test_oddities.py
@@ -1,3 +1,5 @@
+import datetime
+
import pytest
import requests
@@ -490,7 +492,7 @@ def test_staging_push_blocked(env, project, repo, config, users):
f"""\
\
remote: error: GH013: Repository rule violations found for refs/heads/staging.master.
-remote: Review all repository rules
+remote: Review all repository rules at https://github.com/{repo.name}/rules?ref=refs%2Fheads%2Fstaging.master
remote:
remote: - Cannot create ref due to creations being restricted.
remote:
@@ -635,4 +637,118 @@ def test_cron_autodisable(env, code, active):
assert cron.active
cron.trigger()
env.run_crons()
- assert cron.active == active
\ No newline at end of file
+ assert cron.active == active
+
+def test_empty_split(env, project, repo, users, config):
+ b = env['runbot_merge.batch'].create({
+ 'target': project.branch_ids.id,
+ 'merge_date': datetime.datetime.now(),
+ })
+ st = env['runbot_merge.stagings'].create({
+ 'target': project.branch_ids.id,
+ 'active': False,
+ 'state': 'failure',
+ 'staging_end': datetime.datetime.now(),
+ 'staging_batch_ids': [(0, 0, {'runbot_merge_batch_id': b.id})]
+ })
+ env['runbot_merge.split'].create({
+ 'target': project.branch_ids.id,
+ 'staging_id': st.id,
+ 'batch_ids': [],
+ 'original_batches': [],
+ })
+ with repo:
+ [m] = repo.make_commits(None, Commit('initial', tree={'m': 'm'}), ref='heads/master')
+
+ repo.make_commits(m, Commit('thing1', tree={}), ref='heads/other1')
+ pr1 = repo.make_pr(target='master', head='other1')
+ repo.post_status(pr1.head, 'success')
+ pr1.post_comment('hansen r+', config['role_reviewer']['token'])
+ env.run_crons()
+
+ assert to_pr(env, pr1).staging_id
+
+def test_create_commits_count(
+ port, env, project, repo, users, config,
+):
+ with repo:
+ [m] = repo.make_commits(None, Commit('initial', tree={'m': 'm'}), ref='heads/master')
+
+ [c] = repo.make_commits(m, Commit('thing1', tree={}), ref='heads/other1')
+ with repo.disable_hooks():
+ pr = repo.make_pr(target='master', head=c)
+ env.run_crons()
+
+ with pytest.raises(TimeoutError):
+ to_pr(env, pr)
+
+ r = requests.post(
+ f"http://localhost:{port}/runbot_merge/hooks",
+ headers={
+ "X-Github-Event": "pull_request",
+ },
+ json={
+ 'action': 'opened',
+ 'sender': {'login': users['user']},
+ 'repository': {'full_name': repo.name},
+ 'pull_request': {
+ 'number': pr.number,
+ 'state': 'open',
+ 'user': {'login': users['user']},
+ 'head': {'sha': c, 'label': f'{repo.owner}:other1'},
+ 'base': {'ref': 'master', 'repo': {'full_name': repo.name}},
+ 'title': "c",
+ 'commits': 0,
+ 'draft': False,
+ }
+ }
+ )
+ r.raise_for_status()
+
+ pr_id = to_pr(env, pr)
+ assert not pr_id.squash
+ env.run_crons()
+ assert pr_id.squash
+
+def test_sync_commits_count(port, env, project, repo, users, config) -> None:
+ with repo:
+ [m] = repo.make_commits(None, Commit('initial', tree={'m': 'm'}), ref='heads/master')
+
+ [c] = repo.make_commits(m, Commit('thing1', tree={}), ref='heads/other1')
+ pr = repo.make_pr(target='master', head=c)
+ env.run_crons()
+
+ # simulate github being stupid
+ r = requests.post(
+ f"http://localhost:{port}/runbot_merge/hooks",
+ headers={
+ "X-Github-Event": "pull_request",
+ },
+ json={
+ 'action': 'labeled',
+ 'sender': {
+ 'login': users['user'],
+ },
+ 'repository': {
+ 'full_name': repo.name,
+ },
+ 'pull_request': {
+ 'number': pr.number,
+ 'head': {'sha': c},
+ 'title': "c",
+ 'commits': 0,
+ 'base': {
+ 'ref': 'xxx',
+ 'repo': {
+ 'full_name': repo.name,
+ },
+ }
+ }
+ }
+ )
+ r.raise_for_status()
+
+ pr_id = to_pr(env, pr)
+ assert not pr_id.squash
+ env.run_crons()
+ assert pr_id.squash
\ No newline at end of file
diff --git a/runbot_merge/tests/test_provisioning.py b/runbot_merge/tests/test_provisioning.py
index 104cbadaf..531a831ec 100644
--- a/runbot_merge/tests/test_provisioning.py
+++ b/runbot_merge/tests/test_provisioning.py
@@ -1,3 +1,4 @@
+import pytest
import requests
GEORGE = {
@@ -108,6 +109,43 @@ def test_duplicates(env, port):
'sub': '43'
}]) == [0, 0]
+def test_change_login(env, port):
+ """When a `github_login` is updated on an employee, first the original
+ github_login is deprovisioned then the new login is provisioned.
+
+ This causes confusion in the ranks, because deprovisioning removes the
+ two factors we use to find existing partners.
+ """
+ assert provision_user(port, [{
+ 'name': "foo",
+ 'email': 'foo@example.com',
+ 'github_login': 'foo',
+ 'sub': '42'
+ }]) == [1, 0]
+
+ requests.post(f'http://localhost:{port}/runbot_merge/disable_users', json={
+ 'jsonrpc': '2.0',
+ 'id': None,
+ 'method': 'call',
+ 'params': {'github_logins': ['foo']},
+ }).raise_for_status()
+
+ assert provision_user(port, [{
+ 'name': "foo",
+ 'email': 'foo@example.com',
+ 'github_login': 'bar',
+ 'sub': '42'
+ }]) == [0, 1]
+
+ u = env['res.users'].search([('login', '=', 'foo@example.com')])
+ assert u
+ assert u.active
+ internal = env.ref('base.group_user')
+ assert (u.groups_id & internal) == internal
+ assert u.partner_id.active
+ assert u.partner_id.github_login == 'bar'
+
+
def test_no_email(env, port):
""" Provisioning system should ignore email-less entries
"""
diff --git a/runbot_merge/tests/test_statuses.py b/runbot_merge/tests/test_statuses.py
new file mode 100644
index 000000000..16d74b715
--- /dev/null
+++ b/runbot_merge/tests/test_statuses.py
@@ -0,0 +1,131 @@
+import json
+
+import pytest
+
+from utils import Commit, to_pr, seen
+
+
+def test_optional_statuses(env, project, make_repo, users, setreviewers, config):
+ repository = make_repo('repo')
+ env['runbot_merge.repository'].create({
+ 'project_id': project.id,
+ 'name': repository.name,
+ 'status_ids': [(0, 0, {'context': 'l/int', 'prs': 'optional'})]
+ })
+ setreviewers(*project.repo_ids)
+ env['runbot_merge.events_sources'].create({'repository': repository.name})
+
+ with repository:
+ m = repository.make_commits(None, Commit('root', tree={'a': '1'}), ref='heads/master')
+
+ repository.make_commits(m, Commit('pr', tree={'a': '2'}), ref='heads/change')
+ pr = repository.make_pr(target='master', title='super change', head='change')
+ env.run_crons()
+
+ # if an optional status is never received then the PR is valid
+ pr_id = to_pr(env, pr)
+ assert pr_id.state == 'validated'
+
+ # If a run has started, then the PR is pending (not considered valid), this
+ # limits the odds of merging a PR even though it's not valid, as long as the
+ # optional status starts running before all the required statuses arrive
+ # (with a success result).
+ with repository:
+ repository.post_status(pr.head, 'pending', 'l/int')
+ env.run_crons()
+ assert pr_id.state == 'opened'
+
+ # If the status fails, then the PR is rejected.
+ with repository:
+ repository.post_status(pr.head, 'failure', 'l/int')
+ env.run_crons()
+ assert pr_id.state == 'opened'
+
+ # re-run the job / fix the PR
+ with repository:
+ repository.post_status(pr.head, 'pending', 'l/int')
+ env.run_crons()
+ assert pr_id.state == 'opened'
+
+ with repository:
+ repository.post_status(pr.head, 'success', 'l/int')
+ env.run_crons()
+ assert pr_id.state == 'validated'
+
+def test_incomplete_statuses_request(env, project, make_repo, users, setreviewers, config):
+ """If "request missing statuses" is set (configured?) and a PR does not
+ have at least a `pending` for every required status, send a request.
+ """
+ project.request_missing_statuses = True
+ repo = make_repo('repo')
+ env['runbot_merge.repository'].create({
+ 'project_id': project.id,
+ 'name': repo.name,
+ 'status_ids': [(0, 0, {'context': 'l/azy'})]
+ })
+ setreviewers(*project.repo_ids)
+ env['runbot_merge.events_sources'].create({'repository': repo.name})
+
+ with repo:
+ m = repo.make_commits(None, Commit('root', tree={'a': '1'}), ref='heads/master')
+
+ [c] = repo.make_commits(m, Commit('pr', tree={'a': '2'}), ref='heads/change')
+ pr = repo.make_pr(target='master', title='super change', head=c)
+ env.run_crons()
+
+ assert env['saas.calls'].search([]) == env['saas.calls']
+
+ with repo:
+ pr.post_comment('hansen r+', config['role_reviewer']['token'])
+ env.run_crons()
+
+ assert env['saas.calls'].search_read([], ['method', 'url', 'body']) == [{
+ 'id': 1,
+ 'method': 'POST',
+ 'url': 'https://runbot.odoo.com/runbot/request_ci',
+ 'body': json.dumps({"pull_requests": [f"{repo.name}#{pr.number}"]})
+ }]
+
+@pytest.mark.parametrize('status', [
+ 'success', 'failure', 'pending', 'error',
+])
+def test_complete_statuses_norequest(
+ env, project, make_repo, users, setreviewers, config, status
+):
+ """If *any* status has been sent on the status we don't trigger the
+ request.
+ """
+ project.request_missing_statuses = True
+ repo = make_repo('repo')
+ env['runbot_merge.repository'].create({
+ 'project_id': project.id,
+ 'name': repo.name,
+ 'status_ids': [(0, 0, {'context': 'l/azy'}), (0, 0, {'context': 'o/ptional', 'prs': 'optional'})]
+ })
+ setreviewers(*project.repo_ids)
+ env['runbot_merge.events_sources'].create({'repository': repo.name})
+
+ with repo:
+ m = repo.make_commits(None, Commit('root', tree={'a': '1'}), ref='heads/master')
+
+ [c] = repo.make_commits(m, Commit('pr', tree={'a': '2'}), ref='heads/change')
+ pr = repo.make_pr(target='master', title='super change', head=c)
+ repo.post_status(c, status, 'l/azy')
+ env.run_crons()
+
+ with repo:
+ pr.post_comment('hansen r+', config['role_reviewer']['token'])
+ env.run_crons()
+
+ assert env['saas.calls'].search([]) == env['saas.calls']
+ if status not in ('error', 'failure'):
+ assert pr.comments == [
+ seen(env, pr, users),
+ (users['reviewer'], 'hansen r+'),
+ ]
+ else:
+ assert pr.comments == [
+ seen(env, pr, users),
+ (users['reviewer'], 'hansen r+'),
+ (users['user'], "@{reviewer} you may want to rebuild or fix this PR as it has failed CI.".format_map(users)),
+ ]
\ No newline at end of file
diff --git a/runbot_merge/tests/test_statuses_optional.py b/runbot_merge/tests/test_statuses_optional.py
deleted file mode 100644
index 7627676ed..000000000
--- a/runbot_merge/tests/test_statuses_optional.py
+++ /dev/null
@@ -1,49 +0,0 @@
-from utils import Commit, to_pr
-
-
-def test_basic(env, project, make_repo, users, setreviewers, config):
- repository = make_repo('repo')
- env['runbot_merge.repository'].create({
- 'project_id': project.id,
- 'name': repository.name,
- 'status_ids': [(0, 0, {'context': 'l/int', 'prs': 'optional'})]
- })
- setreviewers(*project.repo_ids)
- env['runbot_merge.events_sources'].create({'repository': repository.name})
-
- with repository:
- m = repository.make_commits(None, Commit('root', tree={'a': '1'}), ref='heads/master')
-
- repository.make_commits(m, Commit('pr', tree={'a': '2'}), ref='heads/change')
- pr = repository.make_pr(target='master', title='super change', head='change')
- env.run_crons()
-
- # if an optional status is never received then the PR is valid
- pr_id = to_pr(env, pr)
- assert pr_id.state == 'validated'
-
- # If a run has started, then the PR is pending (not considered valid), this
- # limits the odds of merging a PR even though it's not valid, as long as the
- # optional status starts running before all the required statuses arrive
- # (with a success result).
- with repository:
- repository.post_status(pr.head, 'pending', 'l/int')
- env.run_crons()
- assert pr_id.state == 'opened'
-
- # If the status fails, then the PR is rejected.
- with repository:
- repository.post_status(pr.head, 'failure', 'l/int')
- env.run_crons()
- assert pr_id.state == 'opened'
-
- # re-run the job / fix the PR
- with repository:
- repository.post_status(pr.head, 'pending', 'l/int')
- env.run_crons()
- assert pr_id.state == 'opened'
-
- with repository:
- repository.post_status(pr.head, 'success', 'l/int')
- env.run_crons()
- assert pr_id.state == 'validated'
diff --git a/runbot_merge/views/runbot_merge_project.xml b/runbot_merge/views/runbot_merge_project.xml
index 12b97b027..efae64324 100644
--- a/runbot_merge/views/runbot_merge_project.xml
+++ b/runbot_merge/views/runbot_merge_project.xml
@@ -64,6 +64,7 @@
+
diff --git a/runbot_merge/views/templates.xml b/runbot_merge/views/templates.xml
index e7245a658..ffe0eb910 100644
--- a/runbot_merge/views/templates.xml
+++ b/runbot_merge/views/templates.xml
@@ -23,11 +23,17 @@
-
-
+
+
-
+
+
-
+
@@ -390,17 +396,20 @@
- -
-
-
-
-
-
- _blank
-
+ -
+
+
+
+
+ _blank
-
-
+
+
|