feat: update authored pull branches from mobile (Closes #1360)
All checks were successful
CI / lint (pull_request) Successful in 3m28s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m31s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-24 18:02:23 +00:00
parent a9becd079f
commit b85fe4a0a3
7 changed files with 272 additions and 0 deletions

View File

@ -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; }

View File

@ -1722,6 +1722,11 @@
<div id="pull-review-status" class="small" aria-live="polite">Expand to load changed files and merge readiness.</div>
<button class="pull-retry" id="pull-review-retry" type="button" hidden>Retry review data</button>
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Review data not loaded</span></div>
<section class="pull-branch-update" id="pull-branch-update" aria-labelledby="pull-branch-update-heading" hidden>
<h3 id="pull-branch-update-heading">Bring branch up to date</h3>
<p class="small" id="pull-branch-update-status" role="status" aria-live="assertive"></p>
<button id="update-pull-branch" type="button">Update branch</button>
</section>
<section class="pull-reviewer-summary" aria-labelledby="pull-reviewer-heading">
<h3 id="pull-reviewer-heading">Reviewer status</h3>
<div id="pull-reviewer-status" class="small" aria-live="polite">Reviewer status not loaded.</div>

View File

@ -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;
},
};
}

View File

@ -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:

View File

@ -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)

View File

@ -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))});

View File

@ -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 = []