From b85fe4a0a3dc94eb69e402e9f1a751a815b2e481 Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 24 Aug 2026 18:02:23 +0000 Subject: [PATCH] feat: update authored pull branches from mobile (Closes #1360) --- frontend/dashboard.css | 3 ++ frontend/index.html | 5 +++ frontend/pull-sheet.js | 57 ++++++++++++++++++++++++++++++++++ src/gitea_proxy.py | 56 +++++++++++++++++++++++++++++++++ src/main.py | 34 ++++++++++++++++++++ tests/test_my_work.py | 47 ++++++++++++++++++++++++++++ tests/test_pull_api.py | 70 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 272 insertions(+) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index b820ffa..16e4f39 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -800,6 +800,9 @@ textarea { resize: vertical; min-height: 120px; } .ci-check-log { max-width:100%; max-height:42vh; margin:8px 0; padding:10px; overflow-x:auto; overflow-y:auto; border:1px solid #2a496e; border-radius:6px; background:#07101d; color:#dbeafe; font-size:.78rem; white-space:pre; } .ci-check-recovery-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-top:6px; background:#101b2e; } .ci-check-recovery-actions button { min-height:44px; } +.pull-branch-update { max-width:100%; min-width:0; margin:10px 0; padding:12px; border:1px solid #60a5fa; border-radius:10px; overflow-wrap:anywhere; } +.pull-branch-update h3 { margin:0 0 6px; } +.pull-branch-update button { width:100%; min-height:44px; } .update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); } .update-sheet.open { display:flex; } .update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; } diff --git a/frontend/index.html b/frontend/index.html index 6a75504..1737fee 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1722,6 +1722,11 @@
Expand to load changed files and merge readiness.
CI unknownReview data not loaded
+

Reviewer status

Reviewer status not loaded.
diff --git a/frontend/pull-sheet.js b/frontend/pull-sheet.js index eddff29..78c3042 100644 --- a/frontend/pull-sheet.js +++ b/frontend/pull-sheet.js @@ -231,6 +231,46 @@ function resetReviewRequestControls(doc, detail) { qs('#pull-feedback-status').textContent = ''; } +function resetBranchUpdateControls(doc, detail) { + const panel = doc.querySelector('#pull-branch-update'); + if (!panel) return; + const eligible = detail?.capabilities?.authored === true && detail?.state === 'open' && + detail?.draft !== true && detail?.merged !== true && detail?.mergeable === false && detail?.head_sha; + panel.hidden = !eligible; + doc.querySelector('#update-pull-branch').disabled = false; + doc.querySelector('#pull-branch-update-status').textContent = eligible ? + 'Merge the latest base branch into this pull request. Reviews and checks may run again.' : ''; +} + +function bindBranchUpdateControls(doc, controller, getSelected, getDetail) { + const button = doc.querySelector('#update-pull-branch'); + if (!button || button.dataset.bound === 'true') return; + button.dataset.bound = 'true'; + button.addEventListener('click', async () => { + const item = getSelected?.(); + const detail = getDetail?.(); + if (!item || !detail?.head_sha || detail?.capabilities?.authored !== true || + !globalThis.confirm('Update ' + item.key + ' with the latest base branch? Reviews and checks may run again.')) return; + button.disabled = true; + const status = doc.querySelector('#pull-branch-update-status'); + status.textContent = 'Updating branch…'; + try { + const result = await controller.updateBranch(item, detail.head_sha); + if (getSelected?.() !== item || getDetail?.() !== detail) return; + Object.assign(detail, result, { mergeable:null, ci_state:'pending', reviewers:[] }); + status.textContent = 'Branch updated. Refreshing checks and review state…'; + doc.querySelector('#pull-review-retry')?.click(); + status.textContent = 'Branch updated to ' + result.head_sha.slice(0, 8) + '. Review the refreshed checks before merging.'; + button.hidden = true; + doc.querySelector('#refresh-pull-checks')?.focus(); + } catch (error) { + status.textContent = error.message + ' Reload the latest pull request state before retrying.'; + button.disabled = false; + button.focus(); + } + }); +} + function bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin) { const qs = selector => doc.querySelector(selector); const load = qs('#load-pull-reviewers'); @@ -238,6 +278,7 @@ function bindReviewRequestControls(doc, controller, getSelected, getDetail, getL load.dataset.reviewRequestBound = 'true'; controller.setReviewDetail = detail => { resetReviewRequestControls(doc, detail); + resetBranchUpdateControls(doc, detail); applyOwnershipCapabilities(doc, detail?.capabilities); resetLifecycleControls(doc, detail?.saved_at ? null : detail); controller.edit?.setDetail(detail, Boolean(detail?.saved_at)); @@ -577,6 +618,7 @@ function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) { function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) { bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin); + bindBranchUpdateControls(doc, controller, getSelected, getDetail); bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin); bindCheckRecovery(doc, controller, getSelected, getDetail); bindLifecycleControls(doc, controller, getSelected, getDetail, result => controller.onState?.(result)); @@ -650,6 +692,7 @@ function createPullSheet({ fetchJson, storage, onState, createConversationPager let mergeRequest = null; let checkRequest = null; let ownershipRequest = null; + let branchUpdateRequest = null; let candidateRequest = null; let reviewCandidateRequest = null; let reviewRequestMutation = null; @@ -975,6 +1018,20 @@ function createPullSheet({ fetchJson, storage, onState, createConversationPager }).finally(() => { mergeRequest = null; }); return mergeRequest; }, + updateBranch(item, expectedHeadSha) { + if (branchUpdateRequest) return branchUpdateRequest; + branchUpdateRequest = fetchJson(pathFor(item) + '/update-branch', { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ expected_head_sha: expectedHeadSha }), + }).then(result => { + if (!result?.head_sha || result.head_sha === expectedHeadSha) { + throw new Error('Branch update was not confirmed.'); + } + return result; + }).finally(() => { branchUpdateRequest = null; }); + return branchUpdateRequest; + }, }; } diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 08abd75..fca2566 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -173,6 +173,10 @@ class IssueEditConflictError(ValueError): """Raised when an issue changed after the editor loaded it.""" +class PullUpdateConflictError(ValueError): + """Raised when Gitea cannot merge a pull request's base into its head.""" + + class IssueDependencyInvalidError(ValueError): """Raised when a requested blocker relationship is not valid.""" @@ -2089,6 +2093,58 @@ async def reopen_authored_pull( ) +async def update_authored_pull_branch( + repository: str, number: int, expected_head_sha: 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") != "open" + or pull.get("merged") is True + or pull.get("draft") 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().post( + f"/api/v1/repos/{repository}/pulls/{number}/update", + headers=_auth(), + params={"style": "merge"}, + ) + if response.status_code in {409, 422}: + raise PullUpdateConflictError("Pull request has merge conflicts") + 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 {} + new_head_sha = confirmed_head.get("sha") + if ( + not isinstance(confirmed, dict) + or confirmed.get("number") != number + or confirmed.get("state") != "open" + or confirmed.get("merged") is True + or not isinstance(new_head_sha, str) + or not new_head_sha + or new_head_sha == expected_head_sha + ): + raise ValueError("Gitea did not confirm a new pull request head") + return { + "repository": repository, + "number": number, + "title": confirmed.get("title", ""), + "previous_head_sha": expected_head_sha, + "head_sha": new_head_sha, + "state": "open", + "draft": confirmed.get("draft") is True, + } + + async def request_assigned_pull_review( repository: str, number: int, reviewer: str, expected_head_sha: str ) -> dict: diff --git a/src/main.py b/src/main.py index 41bbbdf..ea509e3 100644 --- a/src/main.py +++ b/src/main.py @@ -6721,6 +6721,40 @@ async def reopen_authored_pull( ) +@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/update-branch") +async def update_authored_pull_branch( + update: PullReadyRequest, + owner: str, + repo: str, + number: int = PathParam(gt=0), +): + repository = f"{owner}/{repo}" + try: + result = await asyncio.wait_for( + gitea_proxy.update_authored_pull_branch( + repository, number, update.expected_head_sha + ), + timeout=ISSUE_ACTION_TIMEOUT_SECONDS, + ) + except gitea_proxy.IssueNotAvailableError: + return JSONResponse( + {"error": "The pull request changed. Reload the latest state before updating its branch."}, + status_code=409, + ) + except gitea_proxy.PullUpdateConflictError: + return JSONResponse( + {"error": "Automatic branch update is unavailable because the branches have conflicts."}, + status_code=422, + ) + except Exception: + return JSONResponse( + {"error": "The branch could not be updated. Please retry."}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse(result) + + @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) diff --git a/tests/test_my_work.py b/tests/test_my_work.py index dc487f0..0bc6163 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -122,6 +122,53 @@ process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)}))); assert result[0]["kind"] == "pull" +def test_authored_pull_branch_update_is_single_flight_and_head_guarded(): + script = f""" +const createPullSheet = require({json.dumps(str(PULL_SHEET))}); +const calls = []; +let resolve; +const pending = new Promise(done => {{ resolve = done; }}); +const controller = createPullSheet({{ + fetchJson:(url, options) => {{ calls.push([url, options]); return pending; }}, + storage:{{}}, +}}); +const item = {{repository:'stackchain/api', number:7}}; +const first = controller.updateBranch(item, 'abc1234'); +const second = controller.updateBranch(item, 'abc1234'); +resolve({{previous_head_sha:'abc1234', head_sha:'def5678'}}); +Promise.all([first, second]).then(results => process.stdout.write(JSON.stringify({{ + same:first === second, + calls:calls.map(([url, options]) => [url, options.method, JSON.parse(options.body)]), + heads:results.map(result => result.head_sha), +}}))).catch(error => {{ console.error(error); process.exit(1); }}); +""" + completed = subprocess.run(["node", "-e", script], text=True, capture_output=True) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "same": True, + "calls": [[ + "api/v1/repos/stackchain/api/pulls/7/update-branch", + "POST", + {"expected_head_sha": "abc1234"}, + ]], + "heads": ["def5678", "def5678"], + } + + +def test_mobile_pull_review_exposes_update_branch_recovery(): + frontend = Path(__file__).parents[1] / "frontend" + html = (frontend / "index.html").read_text() + source = PULL_SHEET.read_text() + css = (frontend / "dashboard.css").read_text() + + assert 'id="update-pull-branch"' in html + assert 'id="pull-branch-update-status"' in html + assert "controller.updateBranch(item, detail.head_sha)" in source + assert "doc.querySelector('#pull-review-retry')?.click()" in source + assert ".pull-branch-update button" in css and "min-height:44px" in css + + def test_my_prs_filter_and_count_include_only_authored_pulls(): script = f""" const buildMyWork = require({json.dumps(str(MY_WORK))}); diff --git a/tests/test_pull_api.py b/tests/test_pull_api.py index 743f012..842f4ae 100644 --- a/tests/test_pull_api.py +++ b/tests/test_pull_api.py @@ -8,6 +8,76 @@ from src import gitea_proxy, main from src.security_event_store import SecurityEventStoreError +@pytest.mark.anyio +async def test_gitea_updates_authored_pull_branch_and_confirms_new_head(): + requests = [] + pull = { + "number": 7, "title": "Ship mobile flow", "state": "open", "draft": False, + "merged": False, "mergeable": 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/update": + assert request.method == "POST" + pull["head"] = {"sha": "def5678"} + return httpx.Response(200, json={"message": "updated"}) + raise AssertionError(f"unexpected request: {request.method} {request.url.path}") + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.update_authored_pull_branch( + "stackchain/api", 7, "abc1234" + ) + finally: + await gitea_proxy.stop_client() + + assert result == { + "repository": "stackchain/api", "number": 7, "title": "Ship mobile flow", + "previous_head_sha": "abc1234", "head_sha": "def5678", "state": "open", + "draft": False, + } + assert [item[:2] for item in requests] == [ + ("GET", "/api/v1/user"), + ("GET", "/api/v1/repos/stackchain/api/pulls/7"), + ("POST", "/api/v1/repos/stackchain/api/pulls/7/update"), + ("GET", "/api/v1/repos/stackchain/api/pulls/7"), + ] + + +@pytest.mark.anyio +async def test_update_pull_branch_api_maps_stale_head_and_conflict(monkeypatch): + outcomes = [ + gitea_proxy.IssueNotAvailableError("stale"), + gitea_proxy.PullUpdateConflictError("conflict"), + ] + + async def update(*_args): + raise outcomes.pop(0) + + monkeypatch.setattr(main.gitea_proxy, "update_authored_pull_branch", update, raising=False) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + stale = await client.post( + "/api/v1/repos/stackchain/api/pulls/7/update-branch", + json={"expected_head_sha": "abc1234"}, + ) + conflict = await client.post( + "/api/v1/repos/stackchain/api/pulls/7/update-branch", + json={"expected_head_sha": "abc1234"}, + ) + + assert stale.status_code == 409 + assert "Reload" in stale.json()["error"] + assert conflict.status_code == 422 + assert "conflicts" in conflict.json()["error"] + + @pytest.mark.anyio async def test_gitea_closes_authored_pull_at_expected_head_and_verifies_state(): requests = []