Close obsolete authored pull requests from mobile with recovery #1357
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py tests/e2e/test_mobile_authored_pull_queue_release.py
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py tests/e2e/test_mobile_authored_pull_queue_release.py tests/e2e/test_mobile_close_authored_pull_release.py
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -966,8 +966,13 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.issue-sheet-actions #watch-issue-detail, .pull-sheet-actions #watch-pull-detail { min-width:0; min-height:44px; }
|
||||
.detail-watch-status:not(:empty) { margin-top:8px; overflow-wrap:anywhere; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-handoff, .pull-ownership, .pull-review-request { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div, .pull-review-request > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff, .pull-ownership, .pull-review-request, .pull-lifecycle { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div, .pull-review-request > div, .pull-lifecycle > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.pull-lifecycle { border-color:#b45353; }
|
||||
.pull-lifecycle button { min-height:44px; border-color:#ef4444; background:#5f1d24; color:#fff; }
|
||||
.pull-close-receipt { margin-top:14px; padding:12px; border:1px solid #4ade80; border-radius:12px; background:#10291e; }
|
||||
.pull-close-receipt h3 { margin-top:0; }
|
||||
.pull-close-receipt button { width:100%; min-height:44px; border-color:#4ade80; }
|
||||
.issue-handoff select, .pull-ownership select, .pull-review-request select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff select, .issue-handoff button { min-height:44px; }
|
||||
.pull-ownership select, .pull-ownership button { min-height:44px; }
|
||||
|
|
|
|||
|
|
@ -1280,7 +1280,7 @@
|
|||
trigger, status: qs('#my-work-action-status'), retryLabel:'Tap the work card to retry.',
|
||||
}, () => {
|
||||
if (!pullController) {
|
||||
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage, onState:refreshMyWorkView });
|
||||
createPullSheet.bindOwnershipControls(document, pullController, () => selectedPull, async item => {
|
||||
const continuing = workSession.checkpointed(item);
|
||||
lastContextSnapshot = createPullSheet.removeFromSnapshot(lastContextSnapshot, item);
|
||||
|
|
@ -1289,7 +1289,6 @@
|
|||
paintMyWork(lastContextSnapshot);
|
||||
return false;
|
||||
}, ()=>selectedPullDetail, ()=>confirmedOwnerLogin);
|
||||
|
||||
}
|
||||
attachReleaseReceipt();
|
||||
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
|
|
|
|||
|
|
@ -1792,6 +1792,20 @@
|
|||
<div id="pull-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
|
||||
</div>
|
||||
</details>
|
||||
<details class="pull-lifecycle" id="pull-lifecycle" hidden>
|
||||
<summary>Close pull request</summary>
|
||||
<div>
|
||||
<p class="small">Close an obsolete pull request without deleting its branch or discussion.</p>
|
||||
<button id="close-authored-pull" type="button" hidden>Close pull request</button>
|
||||
<div id="pull-lifecycle-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</div>
|
||||
</details>
|
||||
<section class="pull-close-receipt" id="pull-close-receipt" aria-labelledby="pull-close-receipt-title" hidden>
|
||||
<h3 id="pull-close-receipt-title" tabindex="-1">Pull request closed.</h3>
|
||||
<p class="small">This pull request was removed from active work. Reopen it if the close was accidental.</p>
|
||||
<button id="reopen-authored-pull" type="button">Reopen pull request</button>
|
||||
<div id="pull-reopen-status" class="small" role="status" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="pull-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="watch-pull-detail" type="button">Watch pull request</button>
|
||||
|
|
|
|||
|
|
@ -198,6 +198,7 @@ function applyOwnershipCapabilities(doc, capabilities = null) {
|
|||
function resetOwnershipControls(doc, item, checkpointed, capabilities = null) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const assigned = applyOwnershipCapabilities(doc, capabilities);
|
||||
resetLifecycleControls(doc, null);
|
||||
qs('#pull-ownership').open = false;
|
||||
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
||||
qs('#pull-handoff-recipient').disabled = true;
|
||||
|
|
@ -238,6 +239,7 @@ function bindReviewRequestControls(doc, controller, getSelected, getDetail, getL
|
|||
controller.setReviewDetail = detail => {
|
||||
resetReviewRequestControls(doc, detail);
|
||||
applyOwnershipCapabilities(doc, detail?.capabilities);
|
||||
resetLifecycleControls(doc, detail?.saved_at ? null : detail);
|
||||
controller.edit?.setDetail(detail, Boolean(detail?.saved_at));
|
||||
const publishable = detail?.state === 'open' && detail?.draft === true &&
|
||||
detail?.author === getLogin?.() && detail?.head_sha;
|
||||
|
|
@ -577,6 +579,7 @@ function bindOwnershipControls(doc, controller, getSelected, finish, getDetail,
|
|||
bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
bindCheckRecovery(doc, controller, getSelected, getDetail);
|
||||
bindLifecycleControls(doc, controller, getSelected, getDetail, result => controller.onState?.(result));
|
||||
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-handoff');
|
||||
|
|
@ -642,7 +645,7 @@ function bindOwnershipControls(doc, controller, getSelected, finish, getDetail,
|
|||
});
|
||||
}
|
||||
|
||||
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
function createPullSheet({ fetchJson, storage, onState, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let mergeRequest = null;
|
||||
let checkRequest = null;
|
||||
|
|
@ -652,6 +655,7 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
|||
let reviewRequestMutation = null;
|
||||
let reviewCancelMutation = null;
|
||||
let readyMutation = null;
|
||||
let lifecycleMutation = null;
|
||||
let editRequest = null;
|
||||
let feedbackRequest = null;
|
||||
const reviewRequests = new Map();
|
||||
|
|
@ -680,6 +684,7 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
|||
}
|
||||
|
||||
return {
|
||||
onState,
|
||||
load(item) {
|
||||
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
|
||||
},
|
||||
|
|
@ -791,6 +796,22 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
|||
}).finally(() => { readyMutation = null; });
|
||||
return readyMutation;
|
||||
},
|
||||
transition(item, expectedHeadSha, action) {
|
||||
if (lifecycleMutation) return lifecycleMutation;
|
||||
lifecycleMutation = fetchJson(pathFor(item) + '/' + action, {
|
||||
method:'PATCH',
|
||||
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify({ expected_head_sha:expectedHeadSha }),
|
||||
}).then(result => {
|
||||
const expectedState = action === 'close' ? 'closed' : 'open';
|
||||
if (result?.number !== item.number || result?.head_sha !== expectedHeadSha ||
|
||||
result?.state !== expectedState || result?.merged === true) {
|
||||
throw new Error('Pull request state change was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { lifecycleMutation = null; });
|
||||
return lifecycleMutation;
|
||||
},
|
||||
loadEditDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
|
||||
|
|
@ -957,6 +978,66 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
|||
};
|
||||
}
|
||||
|
||||
function resetLifecycleControls(doc, detail) {
|
||||
const authored = detail?.capabilities?.authored === true;
|
||||
const lifecycle = doc.querySelector('#pull-lifecycle');
|
||||
if (!lifecycle) return;
|
||||
lifecycle.hidden = !authored || detail?.state !== 'open' || detail?.merged === true;
|
||||
doc.querySelector('#close-authored-pull').hidden = !authored || detail?.state !== 'open' || detail?.merged === true;
|
||||
doc.querySelector('#pull-close-receipt').hidden = true;
|
||||
doc.querySelector('#pull-lifecycle-status').textContent = '';
|
||||
doc.querySelector('#pull-reopen-status').textContent = '';
|
||||
}
|
||||
|
||||
function bindLifecycleControls(doc, controller, selectedPull, selectedDetail, applyResult) {
|
||||
const close = doc.querySelector('#close-authored-pull');
|
||||
const reopen = doc.querySelector('#reopen-authored-pull');
|
||||
close.addEventListener('click', async () => {
|
||||
const item = selectedPull();
|
||||
const detail = selectedDetail();
|
||||
if (!item || !detail?.head_sha || detail?.capabilities?.authored !== true ||
|
||||
!globalThis.confirm('Close ' + item.key + ' at head ' + detail.head_sha.slice(0, 12) + '?')) return;
|
||||
close.disabled = true;
|
||||
doc.querySelector('#pull-lifecycle-status').textContent = 'Closing pull request…';
|
||||
try {
|
||||
const result = await controller.transition(item, detail.head_sha, 'close');
|
||||
Object.assign(detail, result);
|
||||
applyResult(result);
|
||||
doc.querySelector('#pull-lifecycle').hidden = true;
|
||||
doc.querySelector('#pull-close-receipt').hidden = false;
|
||||
doc.querySelector('#pull-lifecycle-status').textContent = '';
|
||||
doc.querySelector('#pull-close-receipt-title').focus();
|
||||
} catch (error) {
|
||||
doc.querySelector('#pull-lifecycle-status').textContent = error.message + ' The pull request remains open; reload and retry.';
|
||||
close.disabled = false;
|
||||
close.focus();
|
||||
}
|
||||
});
|
||||
reopen.addEventListener('click', async () => {
|
||||
const item = selectedPull();
|
||||
const detail = selectedDetail();
|
||||
if (!item || !detail?.head_sha) return;
|
||||
reopen.disabled = true;
|
||||
doc.querySelector('#pull-reopen-status').textContent = 'Reopening pull request…';
|
||||
try {
|
||||
const result = await controller.transition(item, detail.head_sha, 'reopen');
|
||||
Object.assign(detail, result);
|
||||
applyResult(result);
|
||||
doc.querySelector('#pull-close-receipt').hidden = true;
|
||||
doc.querySelector('#pull-lifecycle').hidden = false;
|
||||
close.hidden = false;
|
||||
close.disabled = false;
|
||||
doc.querySelector('#pull-lifecycle-status').textContent = 'Pull request reopened.';
|
||||
doc.querySelector('#pull-reopen-status').textContent = '';
|
||||
close.focus();
|
||||
} catch (error) {
|
||||
doc.querySelector('#pull-reopen-status').textContent = error.message + ' The pull request remains closed; reload and retry.';
|
||||
reopen.disabled = false;
|
||||
reopen.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindCheckRecovery(
|
||||
doc, controller, selectedPull, selectedDetail,
|
||||
confirmRetry = message => globalThis.confirm(message)
|
||||
|
|
@ -1066,6 +1147,8 @@ createPullSheet.renderFile = renderFile;
|
|||
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
|
||||
createPullSheet.removeFromSnapshot = removeFromSnapshot;
|
||||
createPullSheet.sameTarget = sameTarget;
|
||||
createPullSheet.bindLifecycleControls = bindLifecycleControls;
|
||||
createPullSheet.resetLifecycleControls = resetLifecycleControls;
|
||||
createPullSheet.ownershipSelectors = ownershipSelectors;
|
||||
createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
|
||||
createPullSheet.ownershipExitMessage = ownershipExitMessage;
|
||||
|
|
|
|||
|
|
@ -2004,6 +2004,72 @@ def _login_can_manage_pull(login: str, pull: dict) -> bool:
|
|||
)
|
||||
|
||||
|
||||
async def _transition_authored_pull(
|
||||
repository: str,
|
||||
number: int,
|
||||
expected_head_sha: str,
|
||||
*,
|
||||
from_state: str,
|
||||
to_state: str,
|
||||
) -> dict:
|
||||
login, pull = await _current_login_and_target(
|
||||
f"repos/{repository}/pulls/{number}"
|
||||
)
|
||||
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
|
||||
if (
|
||||
pull.get("state") != from_state
|
||||
or pull.get("merged") is True
|
||||
or author.get("login", "").casefold() != login.casefold()
|
||||
or head.get("sha") != expected_head_sha
|
||||
):
|
||||
raise IssueNotAvailableError("Pull request state changed")
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/pulls/{number}",
|
||||
headers=_auth(),
|
||||
json={"state": to_state},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed_response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/pulls/{number}", headers=_auth()
|
||||
)
|
||||
confirmed_response.raise_for_status()
|
||||
confirmed = confirmed_response.json()
|
||||
confirmed_head = confirmed.get("head") if isinstance(confirmed, dict) and isinstance(confirmed.get("head"), dict) else {}
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or confirmed.get("state") != to_state
|
||||
or confirmed.get("merged") is True
|
||||
or confirmed_head.get("sha") != expected_head_sha
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the pull request state")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": confirmed.get("title", ""),
|
||||
"head_sha": expected_head_sha,
|
||||
"state": to_state,
|
||||
"merged": False,
|
||||
}
|
||||
|
||||
|
||||
async def close_authored_pull(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> dict:
|
||||
return await _transition_authored_pull(
|
||||
repository, number, expected_head_sha, from_state="open", to_state="closed"
|
||||
)
|
||||
|
||||
|
||||
async def reopen_authored_pull(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> dict:
|
||||
return await _transition_authored_pull(
|
||||
repository, number, expected_head_sha, from_state="closed", to_state="open"
|
||||
)
|
||||
|
||||
|
||||
async def request_assigned_pull_review(
|
||||
repository: str, number: int, reviewer: str, expected_head_sha: str
|
||||
) -> dict:
|
||||
|
|
|
|||
52
src/main.py
52
src/main.py
|
|
@ -6669,6 +6669,58 @@ async def publish_authored_assigned_pull(
|
|||
return JSONResponse(result)
|
||||
|
||||
|
||||
async def _transition_authored_pull(
|
||||
update: PullReadyRequest,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
transition,
|
||||
action: str,
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
transition(repository, number, update.expected_head_sha),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": f"The pull request changed. Reload before {action}."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": f"The pull request could not be {action}. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/close")
|
||||
async def close_authored_pull(
|
||||
update: PullReadyRequest,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
return await _transition_authored_pull(
|
||||
update, owner, repo, number, gitea_proxy.close_authored_pull, "closed"
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/reopen")
|
||||
async def reopen_authored_pull(
|
||||
update: PullReadyRequest,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
return await _transition_authored_pull(
|
||||
update, owner, repo, number, gitea_proxy.reopen_authored_pull, "reopened"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
|
||||
async def assigned_pull_review_data(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
|
|
|
|||
87
tests/e2e/test_mobile_close_authored_pull_release.py
Normal file
87
tests/e2e/test_mobile_close_authored_pull_release.py
Normal file
|
|
@ -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 = []
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user