feat: commit review feedback fixes from mobile (Closes #1376)
All checks were successful
CI / lint (pull_request) Successful in 3m3s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m18s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-25 03:08:53 +00:00
parent b541c707fc
commit 502fcb30f5
12 changed files with 521 additions and 69 deletions

View File

@ -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 tests/e2e/test_mobile_close_authored_pull_release.py tests/e2e/test_mobile_search_authored_pull_recovery_release.py tests/e2e/test_mobile_source_branch_cleanup_release.py tests/e2e/test_mobile_release_failure_recovery.py
run: python3 -m pytest tests/e2e -q
release-candidate:
runs-on: ubuntu-latest

View File

@ -1310,6 +1310,14 @@ textarea { resize: vertical; min-height: 120px; }
.pull-feedback-pass-heading h4 { margin:2px 0 8px; }
.pull-feedback-pass p { white-space:pre-wrap; }
.pull-feedback-pass textarea { width:100%; min-height:88px; box-sizing:border-box; }
#make-pull-feedback-fix { width:100%; margin-top:8px; border-color:#4ade80; }
.pull-feedback-file-editor { display:grid; min-width:0; gap:8px; margin-top:10px; padding:10px; border:1px solid #4ade80; border-radius:10px; overflow-x:hidden; }
.pull-feedback-file-editor[hidden] { display:none; }
.pull-feedback-file-editor h5 { margin:0; overflow-wrap:anywhere; }
#pull-feedback-file-content { min-height:36dvh; resize:vertical; font:13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size:2; white-space:pre; overflow:auto; }
#pull-feedback-commit-message { box-sizing:border-box; width:100%; min-width:0; }
.pull-feedback-file-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:0; }
@media (max-width:359px) { .pull-feedback-file-actions { grid-template-columns:1fr; } }
.pull-feedback-dispositions { display:grid; grid-template-columns:1fr; gap:6px; margin:10px 0; padding:8px; }
.pull-feedback-dispositions button[aria-pressed="true"] { border-color:#60a5fa; background:#173b63; }
.pull-feedback-navigation { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:8px; }

View File

@ -1741,6 +1741,18 @@
<p class="small" id="pull-feedback-location"></p>
<p id="pull-feedback-body"></p>
<div class="small" id="pull-feedback-context" aria-live="polite"></div>
<button id="make-pull-feedback-fix" type="button" hidden>Make code change</button>
<section class="pull-feedback-file-editor" id="pull-feedback-file-editor" hidden>
<h5>Edit <span id="pull-feedback-file-path"></span></h5>
<label for="pull-feedback-file-content">File content</label>
<textarea id="pull-feedback-file-content" spellcheck="false" maxlength="131072"></textarea>
<label for="pull-feedback-commit-message">Commit message</label>
<input id="pull-feedback-commit-message" maxlength="120" placeholder="fix: address review feedback">
<div class="pull-feedback-file-actions">
<button id="cancel-pull-feedback-fix" type="button" hidden>Cancel change</button>
<button id="commit-pull-feedback-fix" type="button" hidden>Commit to pull branch</button>
</div>
</section>
<fieldset class="pull-feedback-dispositions">
<legend>Disposition</legend>
<button type="button" data-feedback-disposition="addressed">Addressed</button>

View File

@ -432,6 +432,8 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
qs('#finish-pull-feedback').disabled = active.state.posted === true ||
items.some(item => !active.state.responses[String(item.id)]?.disposition);
qs('#request-feedback-review').hidden = active.state.posted !== true;
qs('#make-pull-feedback-fix').hidden = active.detail?.capabilities?.authored !== true ||
active.detail?.state !== 'open' || active.detail?.merged === true || !comment.path;
const found = focusPullFile(doc, comment.path);
qs('#pull-feedback-context').textContent = found ?
'Matching change opened below.' : 'Matching change is unavailable in this preview.';
@ -475,10 +477,77 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
active = { item, detail, reviewer, login,
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
panel.hidden = false;
qs('#pull-feedback-file-editor').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
active.file = null;
qs('#pull-feedback-status').textContent = active.state.posted ?
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
render();
});
qs('#make-pull-feedback-fix').addEventListener('click', async () => {
const comment = current();
if (!active || !comment?.path || active.detail?.capabilities?.authored !== true) return;
const button = qs('#make-pull-feedback-fix');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Loading the current pull file…';
try {
const file = await controller.loadFeedbackFile(active.item, comment.path, active.detail.head_sha);
if (!active || file.head_sha !== active.detail.head_sha || file.path !== comment.path) return;
active.file = file;
qs('#pull-feedback-file-path').textContent = file.path;
qs('#pull-feedback-file-content').value = file.content;
qs('#pull-feedback-commit-message').value = 'fix: address review feedback';
qs('#pull-feedback-file-editor').hidden = false;
qs('#cancel-pull-feedback-fix').hidden = false;
qs('#commit-pull-feedback-fix').hidden = false;
qs('#pull-feedback-status').textContent = 'Edit the bounded file, then commit it to this pull branch.';
qs('#pull-feedback-file-content').focus();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Retry without losing your feedback progress.';
} finally {
button.disabled = false;
}
});
qs('#cancel-pull-feedback-fix').addEventListener('click', () => {
qs('#pull-feedback-file-editor').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
active.file = null;
qs('#make-pull-feedback-fix').focus();
});
qs('#commit-pull-feedback-fix').addEventListener('click', async () => {
if (!active?.file) return;
const content = qs('#pull-feedback-file-content').value;
const message = qs('#pull-feedback-commit-message').value.trim();
if (!message || content === active.file.content) {
qs('#pull-feedback-status').textContent = 'Change the file and enter a commit message before committing.';
return;
}
if (!globalThis.confirm('Commit this change to ' + active.item.key + '?')) return;
const button = qs('#commit-pull-feedback-fix');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Committing and verifying the new pull head…';
try {
const result = await controller.commitFeedbackFix(active.item, {
path:active.file.path, content, message,
expectedHeadSha:active.file.head_sha, expectedBlobSha:active.file.blob_sha,
});
active.detail.head_sha = result.head_sha;
active.state.headSha = result.head_sha;
active.file = null;
save();
qs('#pull-feedback-file-editor').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
qs('#pull-feedback-status').textContent = 'Code change committed. Mark the comment Addressed when satisfied.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Draft kept; reload if the branch changed, or retry.';
button.disabled = false;
button.focus();
}
});
panel.querySelector('.pull-feedback-dispositions').addEventListener('click', event => {
const disposition = event.target.dataset?.feedbackDisposition;
const comment = current();
@ -701,6 +770,7 @@ function createPullSheet({ fetchJson, storage, onState, createConversationPager
let lifecycleMutation = null;
let editRequest = null;
let feedbackRequest = null;
let feedbackFixRequest = null;
const reviewRequests = new Map();
const reviewCache = new Map();
const feedbackRequests = new Map();
@ -764,6 +834,30 @@ function createPullSheet({ fetchJson, storage, onState, createConversationPager
feedbackRequests.set(key, request);
return request;
},
loadFeedbackFile(item, path, expectedHeadSha) {
return fetchJson(pathFor(item) + '/feedback-file?path=' + encodeURIComponent(path) +
'&expected_head_sha=' + encodeURIComponent(expectedHeadSha), {
headers:{ Accept:'application/json' },
});
},
commitFeedbackFix(item, draft) {
if (feedbackFixRequest) return feedbackFixRequest;
feedbackFixRequest = fetchJson(pathFor(item) + '/feedback-file', {
method:'PATCH',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({
path:draft.path, content:draft.content, message:draft.message,
expected_head_sha:draft.expectedHeadSha,
expected_blob_sha:draft.expectedBlobSha,
}),
}).then(result => {
if (!result?.head_sha || result.head_sha === draft.expectedHeadSha || result.path !== draft.path) {
throw new Error('The feedback fix was not confirmed.');
}
return result;
}).finally(() => { feedbackFixRequest = null; });
return feedbackFixRequest;
},
loadChecks(item) {
if (checkRequest) return checkRequest;
checkRequest = fetchJson(pathFor(item) + '/checks', {

View File

@ -2221,6 +2221,124 @@ async def reopen_authored_pull(
)
async def authored_pull_feedback_file(
repository: str, number: int, path: str, expected_head_sha: str
) -> dict:
"""Load one bounded UTF-8 file from an authored same-repository pull head."""
if (
not path or path.startswith("/") or "\\" in path
or any(part in {"", ".", ".."} for part in path.split("/"))
):
raise IssueNotAvailableError("File path is not eligible")
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 {}
base = pull.get("base") if isinstance(pull.get("base"), dict) else {}
head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {}
base_repo = base.get("repo") if isinstance(base.get("repo"), dict) else {}
if (
pull.get("state") != "open" or pull.get("merged") is True
or author.get("login", "").casefold() != login.casefold()
or head.get("sha") != expected_head_sha
or head_repo.get("full_name") != repository
or base_repo.get("full_name") != repository
):
raise IssueNotAvailableError("Pull request state changed")
response = await _get_client().get(
f"/api/v1/repos/{repository}/contents/{quote(path, safe='/')}",
headers=_auth(), params={"ref": expected_head_sha},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
raise IssueNotAvailableError("File is not editable text")
encoded = payload.get("content")
if (
payload.get("type") != "file" or payload.get("encoding") != "base64"
or not isinstance(encoded, str) or not isinstance(payload.get("sha"), str)
):
raise IssueNotAvailableError("File is not editable text")
try:
raw = base64.b64decode(encoded, validate=True)
content = raw.decode("utf-8")
except (ValueError, UnicodeDecodeError):
raise IssueNotAvailableError("File is not editable text") from None
if len(raw) > 128 * 1024 or "\x00" in content:
raise IssueNotAvailableError("File is not editable text")
return {
"repository": repository, "number": number, "path": path,
"head_sha": expected_head_sha, "blob_sha": payload["sha"],
"content": content,
}
async def commit_authored_pull_feedback_fix(
repository: str,
number: int,
path: str,
content: str,
message: str,
expected_head_sha: str,
expected_blob_sha: str,
) -> dict:
"""Commit and verify one race-guarded text-file fix on an authored pull."""
encoded = content.encode("utf-8")
message = message.strip()
if len(encoded) > 128 * 1024 or "\x00" in content or not message or len(message) > 120:
raise IssueNotAvailableError("Feedback fix is outside the editable bounds")
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 {}
base = pull.get("base") if isinstance(pull.get("base"), dict) else {}
head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {}
base_repo = base.get("repo") if isinstance(base.get("repo"), dict) else {}
branch = head.get("ref")
if (
pull.get("state") != "open" or pull.get("merged") is True
or author.get("login", "").casefold() != login.casefold()
or head.get("sha") != expected_head_sha
or head_repo.get("full_name") != repository
or base_repo.get("full_name") != repository
or not isinstance(branch, str) or not branch
):
raise IssueNotAvailableError("Pull request state changed")
current = await authored_pull_feedback_file(
repository, number, path, expected_head_sha
)
if current["blob_sha"] != expected_blob_sha or current["content"] == content:
raise IssueNotAvailableError("File changed or has no new content")
response = await _get_client().put(
f"/api/v1/repos/{repository}/contents/{quote(path, safe='/')}",
headers=_auth(),
json={
"branch": branch, "sha": expected_blob_sha, "message": message,
"content": base64.b64encode(encoded).decode(),
},
)
if response.status_code in {409, 422}:
raise IssueNotAvailableError("Pull request or file changed")
response.raise_for_status()
confirmed_pull = await fetch(f"repos/{repository}/pulls/{number}")
confirmed_head = confirmed_pull.get("head") if isinstance(confirmed_pull, dict) and isinstance(confirmed_pull.get("head"), dict) else {}
new_head_sha = confirmed_head.get("sha")
if 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")
confirmed = await authored_pull_feedback_file(
repository, number, path, new_head_sha
)
if confirmed["content"] != content:
raise ValueError("Gitea did not confirm the committed file content")
return {
"repository": repository, "number": number, "path": path,
"previous_head_sha": expected_head_sha, "head_sha": new_head_sha,
"blob_sha": confirmed["blob_sha"], "message": message,
}
async def update_authored_pull_branch(
repository: str, number: int, expected_head_sha: str
) -> dict:

View File

@ -1152,6 +1152,16 @@ class PullReadyRequest(BaseModel):
)
class PullFeedbackFileUpdate(BaseModel):
path: str = Field(min_length=1, max_length=1024)
content: str = Field(max_length=131_072)
message: str = Field(min_length=1, max_length=120)
expected_head_sha: str = Field(
min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
)
expected_blob_sha: str = Field(min_length=1, max_length=128)
class PullContentUpdate(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
@ -6954,6 +6964,59 @@ async def update_authored_pull_branch(
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/feedback-file")
async def authored_pull_feedback_file(
owner: str,
repo: str,
number: int = PathParam(gt=0),
path: str = Query(min_length=1, max_length=1024),
expected_head_sha: str = Query(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
):
try:
result = await asyncio.wait_for(
gitea_proxy.authored_pull_feedback_file(
f"{owner}/{repo}", number, path, expected_head_sha
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse({"error": "The pull request or file changed. Reload before editing."}, status_code=409)
except Exception:
return JSONResponse(
{"error": "The review file could not be loaded. Please retry."},
status_code=503, headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/feedback-file")
async def commit_authored_pull_feedback_fix(
update: PullFeedbackFileUpdate,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
try:
result = await asyncio.wait_for(
gitea_proxy.commit_authored_pull_feedback_fix(
f"{owner}/{repo}", number, update.path, update.content,
update.message, update.expected_head_sha, update.expected_blob_sha,
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "The pull request or file changed. Your draft was not committed."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The feedback fix could not be confirmed. Keep the draft and 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

@ -0,0 +1,85 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_commits_one_review_feedback_fix(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open", "merged": False, "head_sha": "abc1234",
"capabilities": {"authored": True},
"reviewers": [{
"review_id": 42, "login": "sam", "status": "changes_requested",
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
"comments": [{"id": 99, "path": "src/api.py", "body": "Handle empty state.", "line": 12}],
}],
"files": [{"filename": "src/api.py", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -12 +12 @@", "+return empty"]}],
}
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.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(
"""detail => {
globalThis.confirm = () => true;
globalThis.fixCalls = [];
globalThis.fixItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
globalThis.fixDetail = detail;
globalThis.fixController = createPullSheet({fetchJson: async (path, options = {}) => {
fixCalls.push({path, options});
if ((options.method || 'GET') === 'GET') return {
path:'src/api.py', head_sha:'abc1234', blob_sha:'blob123', content:'return empty\\n'
};
return {path:'src/api.py', previous_head_sha:'abc1234', head_sha:'def5678', blob_sha:'blob456'};
}});
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, false, value => String(value))
).join('');
createPullSheet.bindFeedbackControls(document, fixController,
() => fixItem, () => fixDetail, () => 'alex');
createPullSheet.review(detail, null, document);
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
page.locator('[data-address-review-feedback="42"]').click()
page.locator("#make-pull-feedback-fix").click()
page.locator("#pull-feedback-file-content").fill("return handled\n")
page.locator("#pull-feedback-commit-message").fill("fix: handle empty state")
page.locator("#commit-pull-feedback-fix").click()
page.wait_for_function("fixCalls.length === 2")
result = page.evaluate("""() => ({
calls:fixCalls, head:fixDetail.head_sha,
editorHidden:document.querySelector('#pull-feedback-file-editor').hidden,
status:document.querySelector('#pull-feedback-status').textContent,
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
})""")
browser.close()
assert result["scrollWidth"] <= result["clientWidth"]
assert min(result["heights"]) >= 44
assert result["head"] == "def5678"
assert result["editorHidden"] is True
assert "committed" in result["status"].lower()
assert "path=src%2Fapi.py" in result["calls"][0]["path"]
body = result["calls"][1]["options"]["body"]
assert '"expected_blob_sha":"blob123"' in body
assert '"content":"return handled\\n"' in body

View File

@ -48,7 +48,7 @@ def test_release_promotion_waits_for_tests_and_bundle():
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
def test_release_promotion_waits_for_packaged_mobile_journeys():
def test_release_promotion_waits_for_every_packaged_mobile_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
release = text[text.index(" release-candidate:") :]
@ -58,66 +58,6 @@ def test_release_promotion_waits_for_packaged_mobile_journeys():
assert "pip install -r requirements-e2e.txt" in browser
assert "python3 -m playwright install --with-deps chromium" in browser
assert 'STACKCHAIN_RUN_RELEASE_E2E: "1"' in browser
assert (
"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"
) in browser
assert "python3 -m pytest tests/e2e -q" in browser
assert "test_mobile_" not in browser
assert "needs: [lint, build-release, browser-journey]" in release
def test_browser_job_runs_packaged_today_week_reschedule_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_today_week_reschedule_release.py" in browser
def test_browser_job_gates_latest_pull_review_mobile_journeys():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_address_review_feedback_release.py" in browser
assert "tests/e2e/test_mobile_cancel_pull_review_request_release.py" in browser
def test_browser_job_gates_authored_pull_queue_journey():
text = WORKFLOW.read_text()
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_mobile_source_branch_cleanup_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_source_branch_cleanup_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
def test_browser_job_gates_search_authored_pull_recovery_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_search_authored_pull_recovery_release.py" in browser

View File

@ -130,7 +130,8 @@ def test_detail_watch_is_wired_into_both_mobile_work_details_and_bundle():
assert 'pullDetailWatch.open(item)' in dashboard
assert '#watch-issue-detail' in css
assert '#watch-pull-detail' in css
assert 'tests/e2e/test_mobile_detail_watch_release.py' in workflow
assert "python3 -m pytest tests/e2e -q" in workflow
assert (ROOT / "tests/e2e/test_mobile_detail_watch_release.py").is_file()
def test_detail_watch_ignores_a_late_subscription_response_for_the_previous_item():

View File

@ -18,6 +18,135 @@ def _content_payload(text: str, sha: str) -> dict:
}
@pytest.mark.anyio
async def test_gitea_loads_bounded_text_file_from_authored_pull_head():
pull = {
"number": 7, "state": "open", "merged": False,
"user": {"login": "alex"},
"head": {
"sha": "abc1234", "ref": "alex/review-fix",
"repo": {"full_name": "stackchain/api"},
},
"base": {"repo": {"full_name": "stackchain/api"}},
}
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":
return httpx.Response(200, json=pull)
if request.url.path == "/api/v1/repos/stackchain/api/contents/src/api.py":
assert request.url.params["ref"] == "abc1234"
return httpx.Response(200, json=_content_payload("return empty\n", "blob123"))
raise AssertionError(f"unexpected request: {request.method} {request.url}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.authored_pull_feedback_file(
"stackchain/api", 7, "src/api.py", "abc1234"
)
finally:
await gitea_proxy.stop_client()
assert result == {
"repository": "stackchain/api", "number": 7, "path": "src/api.py",
"head_sha": "abc1234", "blob_sha": "blob123", "content": "return empty\n",
}
@pytest.mark.anyio
async def test_gitea_commits_feedback_fix_and_verifies_advanced_pull_head():
requests = []
pull = {
"number": 7, "state": "open", "merged": False,
"user": {"login": "alex"},
"head": {
"sha": "abc1234", "ref": "alex/review-fix",
"repo": {"full_name": "stackchain/api"},
},
"base": {"repo": {"full_name": "stackchain/api"}},
}
async def handler(request):
requests.append((request.method, request.url.path))
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":
return httpx.Response(200, json=pull)
if request.url.path == "/api/v1/repos/stackchain/api/contents/src/api.py" and request.method == "GET":
ref = request.url.params["ref"]
return httpx.Response(200, json=_content_payload(
"return handled\n" if ref == "def5678" else "return empty\n",
"blob456" if ref == "def5678" else "blob123",
))
if request.url.path == "/api/v1/repos/stackchain/api/contents/src/api.py" and request.method == "PUT":
body = json.loads(request.content)
assert body == {
"branch": "alex/review-fix", "sha": "blob123",
"message": "fix: handle empty state",
"content": base64.b64encode(b"return handled\n").decode(),
}
pull["head"]["sha"] = "def5678"
return httpx.Response(200, json={"commit": {"sha": "def5678"}})
raise AssertionError(f"unexpected request: {request.method} {request.url}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.commit_authored_pull_feedback_fix(
"stackchain/api", 7, "src/api.py", "return handled\n",
"fix: handle empty state", "abc1234", "blob123",
)
finally:
await gitea_proxy.stop_client()
assert result["previous_head_sha"] == "abc1234"
assert result["head_sha"] == "def5678"
assert result["path"] == "src/api.py"
assert requests[-2:] == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/contents/src/api.py"),
]
@pytest.mark.anyio
async def test_feedback_file_api_loads_and_commits_an_authored_pull_fix(monkeypatch):
calls = []
async def load(repository, number, path, head):
calls.append(("load", repository, number, path, head))
return {"path": path, "head_sha": head, "blob_sha": "blob123", "content": "before\n"}
async def commit(repository, number, path, content, message, head, blob):
calls.append(("commit", repository, number, path, content, message, head, blob))
return {"path": path, "previous_head_sha": head, "head_sha": "def5678", "blob_sha": "blob456"}
monkeypatch.setattr(main.gitea_proxy, "authored_pull_feedback_file", load, raising=False)
monkeypatch.setattr(main.gitea_proxy, "commit_authored_pull_feedback_fix", commit, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
loaded = await client.get(
"/api/v1/repos/stackchain/api/pulls/7/feedback-file",
params={"path": "src/api.py", "expected_head_sha": "abc1234"},
)
committed = await client.patch(
"/api/v1/repos/stackchain/api/pulls/7/feedback-file",
json={
"path": "src/api.py", "content": "after\n",
"message": "fix: address review", "expected_head_sha": "abc1234",
"expected_blob_sha": "blob123",
},
)
assert loaded.status_code == 200
assert loaded.json()["blob_sha"] == "blob123"
assert committed.status_code == 200
assert committed.json()["head_sha"] == "def5678"
assert calls == [
("load", "stackchain/api", 7, "src/api.py", "abc1234"),
("commit", "stackchain/api", 7, "src/api.py", "after\n", "fix: address review", "abc1234", "blob123"),
]
@pytest.mark.anyio
async def test_gitea_updates_authored_pull_branch_and_confirms_new_head():
requests = []

View File

@ -248,9 +248,9 @@ def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet()
assert ".today-summary-actions button { min-height:44px;" in css
assert ".today-summary-panel" in css and "overflow-x:hidden" in css
assert ".today-summary-destination" in css and "overflow-wrap:anywhere" in css
assert "tests/e2e/test_mobile_today_summary_release.py" in (
ROOT / ".gitea" / "workflows" / "ci.yml"
).read_text()
workflow = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text()
assert "python3 -m pytest tests/e2e -q" in workflow
assert (ROOT / "tests/e2e/test_mobile_today_summary_release.py").is_file()
def test_summary_uses_the_human_label_from_recap_feedback_rows():

View File

@ -157,7 +157,9 @@ def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_sav
assert 'openWrapUp(handoff.actual_minutes, workedItems)' in recap
assert '.today-wrap-up-actions button { min-height:44px;' in css
assert '.today-wrap-up-panel' in css and 'overflow-x:hidden' in css
assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()
workflow = (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()
assert 'python3 -m pytest tests/e2e -q' in workflow
assert (ROOT / 'tests/e2e/test_mobile_today_wrap_up_release.py').is_file()
def test_wrap_up_repeat_confirmation_does_not_repeat_tomorrow_or_today_operations():