Merge pull request 'Add a mobile review readiness inspector' (#122) from timmy/121-mobile-review-completion into main
This commit is contained in:
commit
f662361b09
|
|
@ -61,15 +61,24 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
.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-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 { 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:hover { border-color:var(--accent); }
|
||||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
.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) {
|
@media (max-width: 600px) {
|
||||||
header { align-items:flex-start; }
|
header { align-items:flex-start; }
|
||||||
.my-work { margin:0; }
|
.my-work { margin:0; }
|
||||||
.my-work-list { grid-template-columns:1fr; }
|
.my-work-list { grid-template-columns:1fr; }
|
||||||
.work-filters { width:100%; }
|
.work-filters { width:100%; }
|
||||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
.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; }
|
.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; }
|
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||||
|
|
@ -189,12 +198,32 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||||
|
|
||||||
<script src="static/markdown.js"></script>
|
<script src="static/markdown.js"></script>
|
||||||
<script src="static/commands.js"></script>
|
<script src="static/commands.js"></script>
|
||||||
<script src="static/widgets.js"></script>
|
<script src="static/widgets.js"></script>
|
||||||
<script src="static/my-work.js"></script>
|
<script src="static/my-work.js"></script>
|
||||||
|
<script src="static/review-sheet.js"></script>
|
||||||
<script src="static/context-poller.js"></script>
|
<script src="static/context-poller.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
|
|
@ -232,6 +261,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
let lastMyWork = [];
|
let lastMyWork = [];
|
||||||
let hasContextSnapshot = false;
|
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 setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
|
||||||
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
||||||
|
|
@ -300,14 +339,58 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
|
|
||||||
function renderMyWork() {
|
function renderMyWork() {
|
||||||
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
|
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
|
||||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item =>
|
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||||
'<a class="my-work-card" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' +
|
const contents =
|
||||||
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : 'Issue') + '</span>' +
|
'<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="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||||
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
|
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
|
||||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '') +
|
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||||||
'</a>'
|
if (item.is_review) {
|
||||||
).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')) + '.</div>';
|
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() {
|
function markMyWorkStale() {
|
||||||
|
|
@ -416,6 +499,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
|
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(''); } } });
|
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-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||||
|
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
|
||||||
|
|
||||||
const contextPoller = createContextPoller({
|
const contextPoller = createContextPoller({
|
||||||
fetchContext: fetchContextSnapshot,
|
fetchContext: fetchContextSnapshot,
|
||||||
|
|
|
||||||
20
frontend/review-sheet.js
Normal file
20
frontend/review-sheet.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,70 @@ async def pull_requests() -> list[dict]:
|
||||||
return list(merged.values())
|
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]:
|
async def activity_events() -> list[dict]:
|
||||||
user = await current_user()
|
user = await current_user()
|
||||||
events = await fetch(f"users/{user['login']}/activities/feeds?limit=20")
|
events = await fetch(f"users/{user['login']}/activities/feeds?limit=20")
|
||||||
|
|
|
||||||
41
src/main.py
41
src/main.py
|
|
@ -2,13 +2,21 @@ import asyncio
|
||||||
import math
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from src.gitea_proxy import activity_events, current_user, repos, issues, pull_requests
|
from src.gitea_proxy import (
|
||||||
from src.models import User, Repo, Issue, PullRequest
|
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.suggestion_engine import compute
|
||||||
from src.views import router as frontend_router
|
from src.views import router as frontend_router
|
||||||
|
|
||||||
|
|
@ -41,7 +49,10 @@ app.include_router(frontend_router)
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def prevent_live_api_caching(request, call_next):
|
async def prevent_live_api_caching(request, call_next):
|
||||||
response = await call_next(request)
|
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"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
return response
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,29 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
|
||||||
assert pulls[1]["work_reasons"] == ["review_requested"]
|
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
|
@pytest.mark.anyio
|
||||||
async def test_context_preserves_repository_and_update_time_for_cross_repo_work(monkeypatch):
|
async def test_context_preserves_repository_and_update_time_for_cross_repo_work(monkeypatch):
|
||||||
async def user():
|
async def user():
|
||||||
|
|
@ -106,3 +129,47 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
|
||||||
"assigned_to_me",
|
"assigned_to_me",
|
||||||
"review_requested",
|
"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
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from src.views import dashboard
|
||||||
|
|
||||||
|
|
||||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
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():
|
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 'data-work-count="review"' in html
|
||||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' 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
61
tests/test_review_api.py
Normal 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
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user