feat: add mobile review readiness inspector (#121)
All checks were successful
CI / lint (pull_request) Successful in 14s
CI / build-frontend (pull_request) Successful in 6s

This commit is contained in:
timmy 2026-08-06 17:09:40 +00:00
parent 329591ebd6
commit 83788c6b82
7 changed files with 391 additions and 9 deletions

View File

@ -61,15 +61,24 @@ textarea { resize: vertical; min-height: 120px; }
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.my-work-card { min-height: 44px; display:block; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card.review-trigger { width:100%; text-align:left; font:inherit; }
.my-work-card:hover { border-color:var(--accent); }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.my-work[data-stale="true"] { border-color:#fcd34d; }
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.review-sheet.open { display:flex; }
.review-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.review-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.review-sheet-body { white-space:pre-wrap; overflow-wrap:anywhere; }
.review-file, .review-history { padding:8px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
.review-action { min-height:44px; }
@media (max-width: 600px) {
header { align-items:flex-start; }
.my-work { margin:0; }
.my-work-list { grid-template-columns:1fr; }
.work-filters { width:100%; }
.work-filter { flex:1 1 calc(50% - 8px); }
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
}
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@ -189,12 +198,32 @@ textarea { resize: vertical; min-height: 120px; }
</div>
</div>
<div class="review-sheet" id="review-sheet" role="dialog" aria-modal="true" aria-labelledby="review-sheet-title">
<section class="review-sheet-panel">
<div class="review-sheet-header">
<div>
<div class="small" id="review-sheet-key"></div>
<h3 id="review-sheet-title">Pull request review</h3>
</div>
<button class="review-action" id="close-review-sheet">Close</button>
</div>
<div id="review-sheet-status" class="small" aria-live="polite">Choose a review request.</div>
<p class="review-sheet-body" id="review-sheet-body"></p>
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
<h2>Changed files</h2>
<div id="review-files" class="muted"></div>
<h2>Review history</h2>
<div id="review-history" class="muted"></div>
</section>
</div>
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
<script src="static/markdown.js"></script>
<script src="static/commands.js"></script>
<script src="static/widgets.js"></script>
<script src="static/my-work.js"></script>
<script src="static/review-sheet.js"></script>
<script src="static/context-poller.js"></script>
<script>
(function(){
@ -232,6 +261,16 @@ textarea { resize: vertical; min-height: 120px; }
}
let lastMyWork = [];
let hasContextSnapshot = false;
let selectedReview = null;
let reviewTrigger = null;
async function fetchReviewJson(url, options) {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Review request failed.');
return payload;
}
const reviewController = createReviewController({ fetchJson: fetchReviewJson });
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
function setClock() { qs('#clock').textContent = fmt(new Date()); }
@ -300,14 +339,58 @@ textarea { resize: vertical; min-height: 120px; }
function renderMyWork() {
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
qs('#my-work-list').innerHTML = visible.length ? visible.map(item =>
'<a class="my-work-card" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' +
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
const contents =
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : 'Issue') + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '') +
'</a>'
).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')) + '.</div>';
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
if (item.is_review) {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
return '<button class="my-work-card review-trigger" data-review-index="' + index + '">' + contents + '</button>';
}
return '<a class="my-work-card" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>';
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')) + '.</div>';
document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button));
});
}
async function openReviewSheet(item, trigger) {
selectedReview = item;
reviewTrigger = trigger;
qs('#review-sheet').classList.add('open');
qs('#review-sheet-key').textContent = item.key;
qs('#review-sheet-title').textContent = item.title;
qs('#review-sheet-status').textContent = 'Loading review details…';
qs('#review-sheet-body').textContent = '';
qs('#review-files').textContent = '';
qs('#review-history').textContent = '';
qs('#open-review-gitea').href = item.url;
qs('#close-review-sheet').focus();
try {
const detail = await reviewController.load(selectedReview);
if (selectedReview !== item) return;
qs('#review-sheet-body').textContent = detail.body || 'No description provided.';
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
qs('#review-files').innerHTML = (detail.files || []).length ? detail.files.map(file =>
'<div class="review-file"><strong>' + escapeHtml(file.filename || 'Unknown file') + '</strong><div class="small">' +
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / ' + Number(file.deletions || 0) + '</div></div>'
).join('') : '<div>No changed files reported.</div>';
qs('#review-history').innerHTML = (detail.reviews || []).length ? detail.reviews.map(review =>
'<div class="review-history"><strong>' + escapeHtml(review.user?.login || 'Reviewer') + '</strong> · ' +
escapeHtml(review.state || 'commented') + (review.body ? '<div class="small">' + escapeHtml(review.body) + '</div>' : '') + '</div>'
).join('') : '<div>No prior reviews.</div>';
qs('#review-sheet-status').textContent = 'Ready to review · by ' + (detail.author || 'unknown author');
} catch (error) {
qs('#review-sheet-status').textContent = error.message + ' Use Open in Gitea or close and retry.';
}
}
function closeReviewSheet() {
qs('#review-sheet').classList.remove('open');
selectedReview = null;
if (reviewTrigger?.isConnected) reviewTrigger.focus();
}
function markMyWorkStale() {
@ -416,6 +499,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
document.addEventListener('keydown', (e) => { if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); qs('#cmd-palette').classList.toggle('open'); if(qs('#cmd-palette').classList.contains('open')){ qs('#cmd-input').focus(); renderCommands(''); } } });
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
const contextPoller = createContextPoller({
fetchContext: fetchContextSnapshot,

20
frontend/review-sheet.js Normal file
View File

@ -0,0 +1,20 @@
function createReviewController({ fetchJson }) {
function endpoint(item) {
const [owner, repo] = String(item.repository || '').split('/');
if (!owner || !repo || !Number.isInteger(Number(item.number))) {
throw new Error('This review link is invalid.');
}
return 'api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) +
'/pulls/' + Number(item.number) + '/review';
}
async function load(item) {
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
}
return { load };
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = createReviewController;
}

View File

@ -55,6 +55,70 @@ async def pull_requests() -> list[dict]:
return list(merged.values())
async def is_requested_review(repository: str, number: int) -> bool:
pulls = await pull_requests()
return any(
isinstance(pull, dict)
and pull.get("number") == number
and "review_requested" in (pull.get("work_reasons") or [])
and isinstance(pull.get("repository"), dict)
and pull["repository"].get("full_name") == repository
for pull in pulls
)
async def pull_review_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
files = await fetch(f"{base}/files")
head_value = pull.get("head")
head: dict = head_value if isinstance(head_value, dict) else {}
sha_value = head.get("sha")
sha = sha_value if isinstance(sha_value, str) else ""
status = await fetch(f"repos/{repository}/commits/{sha}/status")
reviews = await fetch(f"{base}/reviews")
user_value = pull.get("user")
user: dict = user_value if isinstance(user_value, dict) else {}
normalized_files = [
{
"filename": file["filename"],
"status": file.get("status") or "changed",
"additions": file.get("additions") or 0,
"deletions": file.get("deletions") or 0,
}
for file in (files if isinstance(files, list) else [])[:100]
if isinstance(file, dict) and isinstance(file.get("filename"), str)
]
normalized_reviews = [
{
"user": {
"login": (
review["user"].get("login", "")
if isinstance(review.get("user"), dict)
else ""
)
},
"state": review.get("state") or "COMMENT",
"body": review.get("body") or "",
}
for review in (reviews if isinstance(reviews, list) else [])[:50]
if isinstance(review, dict)
]
return {
"repository": repository,
"number": number,
"title": pull.get("title", ""),
"body": pull.get("body") or "",
"url": pull.get("html_url", ""),
"author": user.get("login", ""),
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"files": normalized_files,
"reviews": normalized_reviews,
}
async def activity_events() -> list[dict]:
user = await current_user()
events = await fetch(f"users/{user['login']}/activities/feeds?limit=20")

View File

@ -2,13 +2,21 @@ import asyncio
import math
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from src.gitea_proxy import activity_events, current_user, repos, issues, pull_requests
from src.models import User, Repo, Issue, PullRequest
from src.gitea_proxy import (
activity_events,
current_user,
is_requested_review,
issues,
pull_requests,
pull_review_detail,
repos,
)
from src.models import Issue, PullRequest, Repo, User
from src.suggestion_engine import compute
from src.views import router as frontend_router
@ -41,7 +49,10 @@ app.include_router(frontend_router)
@app.middleware("http")
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
if request.url.path in {"/api/v1/context", "/api/v1/events"}:
if request.url.path in {"/api/v1/context", "/api/v1/events"} or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review")
):
response.headers["Cache-Control"] = "no-store"
return response
@ -247,3 +258,25 @@ async def event_stream():
)
},
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review")
async def review_detail(owner: str, repo: str, number: int):
try:
repository = f"{owner}/{repo}"
if not await asyncio.wait_for(
is_requested_review(repository, number),
timeout=CONTEXT_TIMEOUT_SECONDS,
):
raise HTTPException(status_code=404, detail="Review request not found")
return await asyncio.wait_for(
pull_review_detail(repository, number),
timeout=CONTEXT_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Pull request review details are temporarily unavailable"},
status_code=503,
)

View File

@ -53,6 +53,29 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
assert pulls[1]["work_reasons"] == ["review_requested"]
@pytest.mark.anyio
async def test_requested_review_guard_matches_repository_number_and_reason(monkeypatch):
async def pulls():
return [
{
"number": 7,
"repository": {"full_name": "stackchain/api"},
"work_reasons": ["review_requested"],
},
{
"number": 8,
"repository": {"full_name": "stackchain/api"},
"work_reasons": ["assigned_to_me"],
},
]
monkeypatch.setattr(gitea_proxy, "pull_requests", pulls)
assert await gitea_proxy.is_requested_review("stackchain/api", 7) is True
assert await gitea_proxy.is_requested_review("stackchain/api", 8) is False
assert await gitea_proxy.is_requested_review("stackchain/private", 7) is False
@pytest.mark.anyio
async def test_context_preserves_repository_and_update_time_for_cross_repo_work(monkeypatch):
async def user():
@ -106,3 +129,47 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
"assigned_to_me",
"review_requested",
]
@pytest.mark.anyio
async def test_pull_review_detail_combines_pr_files_status_and_reviews(monkeypatch):
requested_paths = []
async def fake_fetch(path):
requested_paths.append(path)
if path.endswith("/pulls/7"):
return {
"title": "Review API",
"body": "Please check the retry flow.",
"html_url": "https://forge.example/stackchain/api/pulls/7",
"user": {"login": "alex"},
"head": {"sha": "abc123"},
}
if path.endswith("/files"):
return [
{"filename": "src/api.py", "status": "modified", "additions": 8, "deletions": 2},
None,
]
if "/commits/abc123/status" in path:
return {"state": "success"}
return [
{"user": {"login": "sam"}, "state": "APPROVED", "body": "Looks good"},
"malformed",
]
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
detail = await gitea_proxy.pull_review_detail("stackchain/api", 7)
assert requested_paths == [
"repos/stackchain/api/pulls/7",
"repos/stackchain/api/pulls/7/files",
"repos/stackchain/api/commits/abc123/status",
"repos/stackchain/api/pulls/7/reviews",
]
assert detail["author"] == "alex"
assert detail["ci_state"] == "success"
assert detail["files"][0]["filename"] == "src/api.py"
assert detail["reviews"][0]["state"] == "APPROVED"
assert len(detail["files"]) == 1
assert len(detail["reviews"]) == 1

View File

@ -8,6 +8,7 @@ from src.views import dashboard
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
@ -143,3 +144,55 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
assert 'data-work-count="review"' in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
def test_review_controller_loads_encoded_cross_repo_detail_path():
script = f"""
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
let request;
const controller = createReviewController({{ fetchJson: async (url, options) => {{
request = {{ url, accept: options.headers.Accept }};
return {{ title: 'Review API' }};
}} }});
controller.load({{ repository: 'stackchain/api', number: 7 }}).then(detail =>
process.stdout.write(JSON.stringify({{ request, title: detail.title }}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"request": {
"url": "api/v1/repos/stackchain/api/pulls/7/review",
"accept": "application/json",
},
"title": "Review API",
}
@pytest.mark.anyio
async def test_review_requests_open_an_accessible_mobile_detail_sheet():
html = await dashboard()
assert 'id="review-sheet"' in html
assert 'role="dialog"' in html and 'aria-modal="true"' in html
assert 'id="review-sheet-status"' in html and 'aria-live="polite"' in html
assert 'id="open-review-gitea"' in html and 'rel="noopener noreferrer"' in html
assert '<script src="static/review-sheet.js"></script>' in html
assert '@media (max-width: 600px)' in html
assert '.review-sheet-panel' in html and 'width:100%' in html
assert '.review-action' in html and 'min-height:44px' in html
@pytest.mark.anyio
async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
html = await dashboard()
assert 'data-review-index' in html
assert "reviewController.load(selectedReview)" in html
assert "review-files" in html
assert "review-history" in html
assert "open-review-gitea" in html
assert "reviewController.submit" not in html

61
tests/test_review_api.py Normal file
View File

@ -0,0 +1,61 @@
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_review_detail_endpoint_returns_normalized_no_store_payload(monkeypatch):
async def requested(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def detail(repository, number):
assert (repository, number) == ("stackchain/api", 7)
return {
"repository": repository,
"number": number,
"title": "Review API",
"body": "Check retries",
"url": "https://forge.example/stackchain/api/pulls/7",
"author": "alex",
"ci_state": "success",
"files": [{"filename": "src/api.py", "status": "modified", "additions": 8, "deletions": 2}],
"reviews": [{"user": {"login": "sam"}, "state": "APPROVED", "body": "Good"}],
}
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main, "pull_review_detail", detail)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/repos/stackchain/api/pulls/7/review")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["files"][0]["filename"] == "src/api.py"
@pytest.mark.anyio
async def test_review_detail_rejects_pulls_not_requested_from_service_user(monkeypatch):
async def requested(repository, number):
return False
monkeypatch.setattr(main, "is_requested_review", requested)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/repos/private/secret/pulls/9/review")
assert response.status_code == 404
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_public_review_endpoint_does_not_expose_service_token_mutations():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/review",
json={"action": "approve"},
)
assert response.status_code == 405