feat: close authored pulls with recovery (Closes #1356)
This commit is contained in:
1 parent
fde3ba21ca
commit
77f22f3cd5
10 files changed
+442
-6
No files matched your search
@@ -0,0 +1,87 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.environ.get("STACKCHAIN_RUN_RELEASE_E2E") != "1",
|
||||
reason="release browser journey is opt-in",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
|
||||
def test_mobile_author_closes_obsolete_pull_then_recovers_it(viewport):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
|
||||
with playwright.sync_playwright() as runtime:
|
||||
try:
|
||||
browser = runtime.chromium.launch(headless=True)
|
||||
except Exception as error:
|
||||
pytest.skip(f"Chromium is not installed: {error}")
|
||||
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
page.set_content(html, wait_until="domcontentloaded")
|
||||
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
|
||||
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
window.lifecycleCalls = [];
|
||||
window.lifecycleItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
|
||||
window.lifecycleDetail = {
|
||||
number:7, title:'Obsolete experiment', author:'timmy', state:'open',
|
||||
merged:false, head_sha:'abc1234', capabilities:{authored:true, assigned:false},
|
||||
};
|
||||
const controller = createPullSheet({
|
||||
storage:null,
|
||||
fetchJson:async (path, options) => {
|
||||
lifecycleCalls.push({path, method:options.method, body:JSON.parse(options.body)});
|
||||
const state = path.endsWith('/close') ? 'closed' : 'open';
|
||||
return {repository:'stackchain/api', number:7, title:'Obsolete experiment',
|
||||
head_sha:'abc1234', state, merged:false};
|
||||
},
|
||||
});
|
||||
createPullSheet.bindLifecycleControls(
|
||||
document, controller, () => lifecycleItem, () => lifecycleDetail,
|
||||
result => { lifecycleDetail = {...lifecycleDetail, ...result}; }
|
||||
);
|
||||
createPullSheet.resetLifecycleControls(document, lifecycleDetail);
|
||||
document.querySelector('#pull-sheet').classList.add('open');
|
||||
}"""
|
||||
)
|
||||
|
||||
page.locator("#pull-lifecycle summary").click()
|
||||
close = page.get_by_role("button", name="Close pull request")
|
||||
before = close.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.hidden})")
|
||||
close.click()
|
||||
page.get_by_text("Pull request closed.").wait_for()
|
||||
reopen = page.get_by_role("button", name="Reopen pull request")
|
||||
receipt = reopen.evaluate("button => ({height:button.getBoundingClientRect().height, hidden:button.closest('[hidden]') !== null})")
|
||||
reopen.click()
|
||||
page.get_by_text("Pull request reopened.").wait_for()
|
||||
metrics = page.evaluate(
|
||||
"""() => ({
|
||||
calls:lifecycleCalls,
|
||||
state:lifecycleDetail.state,
|
||||
closeHidden:document.querySelector('#close-authored-pull').hidden,
|
||||
receiptHidden:document.querySelector('#pull-close-receipt').hidden,
|
||||
focused:document.activeElement === document.querySelector('#close-authored-pull'),
|
||||
scrollWidth:document.documentElement.scrollWidth,
|
||||
clientWidth:document.documentElement.clientWidth,
|
||||
})"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert before["hidden"] is False
|
||||
assert before["height"] >= 44
|
||||
assert receipt["height"] >= 44
|
||||
assert receipt["hidden"] is False
|
||||
assert [call["path"].rsplit("/", 1)[-1] for call in metrics["calls"]] == ["close", "reopen"]
|
||||
assert all(call["body"] == {"expected_head_sha": "abc1234"} for call in metrics["calls"])
|
||||
assert metrics["state"] == "open"
|
||||
assert metrics["closeHidden"] is False
|
||||
assert metrics["receiptHidden"] is True
|
||||
assert metrics["focused"] is True
|
||||
assert metrics["scrollWidth"] <= metrics["clientWidth"]
|
||||
@@ -100,3 +100,10 @@ def test_browser_job_gates_authored_pull_queue_journey():
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
|
||||
assert "tests/e2e/test_mobile_authored_pull_queue_release.py" in browser
|
||||
|
||||
|
||||
def test_browser_job_gates_authored_pull_close_recovery_journey():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
|
||||
assert "tests/e2e/test_mobile_close_authored_pull_release.py" in browser
|
||||
@@ -8,6 +8,129 @@ from src import gitea_proxy, main
|
||||
from src.security_event_store import SecurityEventStoreError
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_closes_authored_pull_at_expected_head_and_verifies_state():
|
||||
requests = []
|
||||
pull = {
|
||||
"number": 7,
|
||||
"title": "Obsolete experiment",
|
||||
"state": "open",
|
||||
"merged": False,
|
||||
"user": {"login": "alex"},
|
||||
"head": {"sha": "abc1234"},
|
||||
}
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path, request.content))
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "alex"})
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "GET":
|
||||
return httpx.Response(200, json=pull)
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "PATCH":
|
||||
assert json.loads(request.content) == {"state": "closed"}
|
||||
pull["state"] = "closed"
|
||||
return httpx.Response(200, json=pull)
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.close_authored_pull("stackchain/api", 7, "abc1234")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"repository": "stackchain/api",
|
||||
"number": 7,
|
||||
"title": "Obsolete experiment",
|
||||
"head_sha": "abc1234",
|
||||
"state": "closed",
|
||||
"merged": False,
|
||||
}
|
||||
assert [item[:2] for item in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_reopens_authored_unmerged_pull_at_expected_head():
|
||||
pull = {
|
||||
"number": 7,
|
||||
"title": "Obsolete experiment",
|
||||
"state": "closed",
|
||||
"merged": False,
|
||||
"user": {"login": "alex"},
|
||||
"head": {"sha": "abc1234"},
|
||||
}
|
||||
|
||||
async def handler(request):
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "alex"})
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "GET":
|
||||
return httpx.Response(200, json=pull)
|
||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "PATCH":
|
||||
assert json.loads(request.content) == {"state": "open"}
|
||||
pull["state"] = "open"
|
||||
return httpx.Response(200, json=pull)
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.reopen_authored_pull("stackchain/api", 7, "abc1234")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["state"] == "open"
|
||||
assert result["head_sha"] == "abc1234"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_author_can_close_then_reopen_pull_through_api(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def transition(repository, number, expected_head_sha, state):
|
||||
calls.append((state, repository, number, expected_head_sha))
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"head_sha": expected_head_sha,
|
||||
"state": state,
|
||||
"merged": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy,
|
||||
"close_authored_pull",
|
||||
lambda repository, number, head: transition(repository, number, head, "closed"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy,
|
||||
"reopen_authored_pull",
|
||||
lambda repository, number, head: transition(repository, number, head, "open"),
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
closed = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/close",
|
||||
json={"expected_head_sha": "abc1234"},
|
||||
)
|
||||
reopened = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/reopen",
|
||||
json={"expected_head_sha": "abc1234"},
|
||||
)
|
||||
|
||||
assert closed.status_code == 200
|
||||
assert closed.json()["state"] == "closed"
|
||||
assert reopened.status_code == 200
|
||||
assert reopened.json()["state"] == "open"
|
||||
assert calls == [
|
||||
("closed", "stackchain/api", 7, "abc1234"),
|
||||
("open", "stackchain/api", 7, "abc1234"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_author_can_publish_assigned_draft_at_expected_head(monkeypatch):
|
||||
calls = []
|
||||
|
||||
Reference in new issue
Block a user