feat: update authored pull branches from mobile (Closes #1360)
This commit is contained in:
parent
a9becd079f
commit
b85fe4a0a3
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
34
src/main.py
34
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)
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user