Compare commits

...
Author SHA1 Message Date
timmy 30a014b619 Merge pull request 'Submit pull request reviews from mobile My Work' (#156) from timmy/155-mobile-review-submit into main
CI / lint (push) Successful in 11s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-07 02:02:03 +00:00
timmy 9b81dce8aa feat: submit pull reviews from mobile My Work (#155)
CI / lint (pull_request) Successful in 11s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 02:01:18 +00:00
timmy 7f5e671f8d Merge pull request 'Reply to unread updates from mobile My Work' (#154) from timmy/153-mobile-update-replies into main
CI / lint (push) Successful in 11s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 01:02:56 +00:00
timmy 83de2a7499 feat: reply to unread updates in My Work (#153)
CI / lint (pull_request) Successful in 11s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 01:01:59 +00:00
timmy 62ebabb132 Merge pull request 'Read and triage unread updates inside mobile My Work' (#152) from timmy/151-mobile-update-reader into main
CI / lint (push) Successful in 11s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 00:36:31 +00:00
10 changed files with 811 additions and 22 deletions

No files matched your search

+7 -3
View File
@@ -14,12 +14,16 @@ python3 -m pip install -r requirements.txt
```
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
token that can read dashboard data and update the authenticated user's
notification threads, then start the API and bundled frontend:
token that can read dashboard data, update the authenticated user's notification
threads, create issue comments, and submit pull-request reviews. Pull-request
replies use Gitea's issue-comment API; native Comment, Approve, and Request changes
reviews require repository write permission. Serve the dashboard only to trusted
users on its own origin; cross-origin API
access is intentionally disabled. Then start the API and bundled frontend:
```bash
export GITEA_URL='https://forge.example.com'
export GITEA_TOKEN='<read-scoped-token>'
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
uvicorn src.main:app --host 127.0.0.1 --port 8000
```
+92 -1
View File
@@ -88,7 +88,7 @@ textarea { resize: vertical; min-height: 120px; }
.review-feedback { display:grid; gap:8px; }
.review-feedback label { display:grid; gap:6px; }
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.review-handoff button { min-height:44px; }
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
.review-handoff-link[hidden] { display:none; }
@@ -107,7 +107,11 @@ textarea { resize: vertical; min-height: 120px; }
.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; }
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.update-sheet-header button { min-height:44px; }
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.update-reply { display:grid; gap:8px; margin-top:16px; }
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
.update-reply button { min-height:44px; width:100%; }
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@@ -268,6 +272,13 @@ textarea { resize: vertical; min-height: 120px; }
<summary><h2>Subject context</h2></summary>
<p class="update-sheet-content muted" id="update-subject-body"></p>
</details>
<section class="update-reply" aria-labelledby="update-reply-title">
<h2 id="update-reply-title">Reply</h2>
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<button id="send-update-reply" type="button">Send reply</button>
<div id="update-reply-status" class="small" aria-live="assertive"></div>
</section>
<div class="update-sheet-actions">
<button id="mark-update-read-next" type="button">Mark read &amp; next</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
@@ -307,6 +318,8 @@ textarea { resize: vertical; min-height: 120px; }
<textarea id="review-summary" placeholder="Summarize your review"></textarea>
</label>
<div class="review-handoff">
<button id="submit-review">Submit review</button>
<span class="small" id="review-submit-status" aria-live="assertive"></span>
<button id="copy-review-feedback">Copy feedback &amp; continue in Gitea</button>
<span class="small" id="review-handoff-status" aria-live="polite"></span>
<textarea class="review-copy-fallback" id="review-copy-fallback" readonly hidden aria-label="Feedback to copy manually"></textarea>
@@ -372,6 +385,7 @@ textarea { resize: vertical; min-height: 120px; }
let progress = null;
let draft = null;
let reviewFiles = [];
let selectedReviewHead = '';
let bulkConfirmationPending = false;
let bulkMarkPending = false;
let reviewHandoffPending = false;
@@ -433,6 +447,17 @@ textarea { resize: vertical; min-height: 120px; }
return payload;
}
async function postNotificationReply(notificationId, body) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Posting the reply failed.');
return payload;
}
const notificationAcknowledger = createNotificationAcknowledger({
markRead: markNotificationRead,
onItems: items => {
@@ -468,6 +493,11 @@ textarea { resize: vertical; min-height: 120px; }
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const notificationReplier = createNotificationReplier({
post: postNotificationReply,
storage: localStorage,
onStatus: message => { qs('#update-reply-status').textContent = message; },
});
const notificationReader = createNotificationReader({
load: fetchNotificationDetail,
markRead: markNotificationRead,
@@ -482,6 +512,9 @@ textarea { resize: vertical; min-height: 120px; }
qs('#update-subject-type').textContent = item.subject_type || 'Update';
qs('#update-subject-state').textContent = item.state || '';
qs('#open-update-gitea').href = item.url || '#';
qs('#update-reply').value = notificationReplier.loadDraft(item);
qs('#update-reply-status').textContent = '';
qs('#send-update-reply').disabled = false;
qs('#retry-update-load').hidden = true;
qs('#keep-update-unread').focus();
},
@@ -687,8 +720,11 @@ textarea { resize: vertical; min-height: 120px; }
qs('#review-handoff-link').hidden = true;
qs('#review-handoff-link').href = item.url;
qs('#review-handoff-status').textContent = '';
qs('#review-submit-status').textContent = '';
qs('#submit-review').disabled = true;
draft = null;
reviewFiles = [];
selectedReviewHead = '';
qs('#close-review-sheet').focus();
try {
const detail = await reviewController.load(selectedReview);
@@ -712,6 +748,7 @@ textarea { resize: vertical; min-height: 120px; }
files: detail.files || [],
});
reviewFiles = detail.files || [];
selectedReviewHead = detail.head_sha || '';
draft = createReviewController.createDraft({
storage: localStorage,
repository: item.repository,
@@ -741,6 +778,7 @@ textarea { resize: vertical; min-height: 120px; }
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');
qs('#submit-review').disabled = false;
} catch (error) {
if (selectedReview !== item) return;
qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.';
@@ -755,6 +793,7 @@ textarea { resize: vertical; min-height: 120px; }
progress = null;
draft = null;
reviewFiles = [];
selectedReviewHead = '';
if (reviewTrigger?.isConnected) reviewTrigger.focus();
}
@@ -910,6 +949,27 @@ textarea { resize: vertical; min-height: 120px; }
qs('#retry-update-load').addEventListener('click', () => {
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
});
qs('#update-reply').addEventListener('input', event => {
if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value);
});
qs('#send-update-reply').addEventListener('click', async () => {
if (!selectedUpdate) return;
const body = qs('#update-reply').value.trim();
if (!body) {
qs('#update-reply-status').textContent = 'Write a reply before sending.';
qs('#update-reply').focus();
return;
}
qs('#send-update-reply').disabled = true;
const result = await notificationReplier.submit(selectedUpdate, body);
qs('#send-update-reply').disabled = false;
if (result) {
qs('#update-reply').value = '';
qs('#mark-update-read-next').focus();
} else {
qs('#update-reply').focus();
}
});
qs('#mark-update-read-next').addEventListener('click', async () => {
qs('#mark-update-read-next').disabled = true;
try {
@@ -927,6 +987,37 @@ textarea { resize: vertical; min-height: 120px; }
});
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
qs('#submit-review').addEventListener('click', async () => {
if (!draft || !selectedReview || !selectedReviewHead) return;
const snapshot = draft.snapshot();
const labels = { approve: 'Approve', request_changes: 'Request changes' };
if (labels[snapshot.decision] && !window.confirm(
labels[snapshot.decision] + ' ' + selectedReview.repository + '#' + selectedReview.number + '?'
)) return;
const button = qs('#submit-review');
button.disabled = true;
qs('#review-submit-status').textContent = 'Submitting review…';
try {
const result = await reviewController.submit(selectedReview, {
decision: snapshot.decision,
body: createReviewController.formatFeedback(snapshot, reviewFiles),
expected_head_sha: selectedReviewHead,
});
draft.clear();
progress?.clear();
qs('#review-decision').value = 'comment';
qs('#review-summary').value = '';
document.querySelectorAll('.review-note').forEach(note => { note.value = ''; });
if (progress) showReviewProgress(progress.snapshot());
qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.';
await load();
button.focus();
} catch (error) {
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.';
button.disabled = false;
button.focus();
}
});
qs('#copy-review-feedback').addEventListener('click', async () => {
if (!draft || !selectedReview || reviewHandoffPending) return;
reviewHandoffPending = true;
+34
View File
@@ -226,6 +226,39 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
};
}
function createNotificationReplier({ post, storage, onStatus }) {
let pending = false;
const keyFor = item => 'stackchain.update-reply.v1.' + item.notification_id;
return {
loadDraft(item) {
try { return storage.getItem(keyFor(item)) || ''; }
catch (_error) { return ''; }
},
saveDraft(item, body) {
try { storage.setItem(keyFor(item), body); }
catch (_error) { /* Keep the editable textarea as the fallback. */ }
},
async submit(item, body) {
if (pending) return false;
pending = true;
this.saveDraft(item, body);
onStatus('Sending reply…');
try {
const result = await post(item.notification_id, body);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
onStatus('Reply posted. You can mark this update read when ready.');
return result;
} catch (_error) {
onStatus('Could not send reply. Your draft is safe; retry.');
return false;
} finally {
pending = false;
}
},
};
}
function filterMyWork(items, selectedFilter) {
if (selectedFilter === 'all') return items;
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
@@ -263,5 +296,6 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
buildMyWork.createNotificationPager = createNotificationPager;
buildMyWork.createNotificationReader = createNotificationReader;
buildMyWork.createNotificationReplier = createNotificationReplier;
module.exports = buildMyWork;
}
+28 -3
View File
@@ -1,4 +1,6 @@
function createReviewController({ fetchJson }) {
let pendingSubmission = null;
function endpoint(item) {
const [owner, repo] = String(item.repository || '').split('/');
if (!owner || !repo || !Number.isInteger(Number(item.number))) {
@@ -12,7 +14,17 @@ function createReviewController({ fetchJson }) {
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
}
return { load };
function submit(item, payload) {
if (pendingSubmission) return pendingSubmission;
pendingSubmission = fetchJson(endpoint(item), {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).finally(() => { pendingSubmission = null; });
return pendingSubmission;
}
return { load, submit };
}
function diffLineClass(line) {
@@ -90,7 +102,14 @@ function createProgress({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, markReviewed };
function clear() {
reviewed = [];
newHead = false;
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
return snapshot();
}
return { snapshot, markReviewed, clear };
}
function createDraft({ storage, repository, number, headSha, files }) {
@@ -140,7 +159,13 @@ function createDraft({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, setNote, setSummary, setDecision };
function clear() {
draft = { notes: {}, summary: '', decision: 'comment' };
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
return snapshot();
}
return { snapshot, setNote, setSummary, setDecision, clear };
}
function formatFeedback(draft, files) {
+76
View File
@@ -1,5 +1,6 @@
import asyncio
import os
import re
import shlex
from typing import Any
from urllib.parse import urlsplit
@@ -13,6 +14,10 @@ REVIEW_DIFF_MAX_LINES = 400
_client: httpx.AsyncClient | None = None
class StaleReviewError(ValueError):
"""Raised before mutation when a pull request head changed during review."""
def _auth() -> dict[str, str]:
headers: dict[str, str] = {"Accept": "application/json"}
if GITEA_TOKEN:
@@ -279,6 +284,40 @@ async def notification_detail(thread_id: int) -> dict:
}
async def reply_to_notification(thread_id: int, body: str) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
raise ValueError("Gitea notification thread response was not an object")
repository = thread.get("repository")
subject = thread.get("subject")
if not isinstance(repository, dict) or not isinstance(subject, dict):
raise ValueError("Notification does not identify a conversation")
repository_name = repository.get("full_name")
subject_path = _gitea_api_path(subject.get("url"))
match = re.fullmatch(
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
)
if (
not match
or match.group(1) != repository_name
or subject.get("type") not in {"Issue", "Pull"}
):
raise ValueError("Notification subject is not a supported conversation")
response = await _get_client().post(
f"/api/v1/repos/{match.group(1)}/issues/{match.group(3)}/comments",
headers=_auth(),
json={"body": body},
)
response.raise_for_status()
comment = response.json()
if not isinstance(comment, dict):
raise ValueError("Gitea comment response was not an object")
return {
"id": comment.get("id"),
"url": _safe_web_url(comment.get("html_url")),
}
async def pull_requests() -> list[dict]:
assigned, review_requested = await asyncio.gather(
fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"),
@@ -381,6 +420,43 @@ async def pull_review_detail(repository: str, number: int) -> dict:
}
async def submit_pull_review(
repository: str,
number: int,
expected_head_sha: str,
decision: str,
body: str,
) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
head = pull.get("head") if isinstance(pull, dict) else None
current_sha = head.get("sha") if isinstance(head, dict) else None
if current_sha != expected_head_sha:
raise StaleReviewError("Pull request changed while it was being reviewed")
response = await _get_client().post(
f"/api/v1/{base}/reviews",
headers=_auth(),
json={
"body": body,
"event": {
"comment": "COMMENT",
"approve": "APPROVE",
"request_changes": "REQUEST_CHANGES",
}[decision],
"commit_id": current_sha,
},
)
response.raise_for_status()
review = response.json()
if not isinstance(review, dict):
raise ValueError("Gitea review response was not an object")
return {
"id": review.get("id"),
"state": review.get("state") or "COMMENT",
"url": _safe_web_url(review.get("html_url")),
}
async def activity_events(user: dict | None = None) -> list[dict]:
if user is None:
user = await current_user()
+85 -10
View File
@@ -5,10 +5,9 @@ from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt
from pydantic import BaseModel, Field, PositiveInt, field_validator
from src import gitea_proxy
from src.gitea_proxy import (
@@ -82,6 +81,31 @@ class NotificationReadBatch(BaseModel):
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
class NotificationReply(BaseModel):
body: str = Field(min_length=1, max_length=10_000)
@field_validator("body")
@classmethod
def strip_body(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("reply must not be blank")
return value
class PullReviewSubmission(BaseModel):
decision: str
body: str = Field(max_length=10_000)
expected_head_sha: str
@field_validator("decision")
@classmethod
def validate_decision(cls, value: str) -> str:
if value not in {"comment", "approve", "request_changes"}:
raise ValueError("unsupported review decision")
return value
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
user_model = User(
id=user_data["id"],
@@ -127,14 +151,6 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
]
return compute(user_model, repo_models, issue_models, pr_models).model_dump()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
app.include_router(frontend_router)
@@ -626,6 +642,28 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
return JSONResponse({"id": thread_id, "status": "read"})
@app.post("/api/v1/notifications/{thread_id}/reply", status_code=201)
async def reply_to_notification(
reply: NotificationReply, thread_id: int = PathParam(gt=0)
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.reply_to_notification(thread_id, reply.body),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{
"error": (
"The reply could not be posted. Your draft is safe; please retry."
)
},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, status_code=201)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review")
async def review_detail(owner: str, repo: str, number: int):
async def load_requested_review():
@@ -655,3 +693,40 @@ async def review_detail(owner: str, repo: str, number: int):
{"error": "Pull request review details are temporarily unavailable"},
status_code=503,
)
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
async def submit_review(
submission: PullReviewSubmission, owner: str, repo: str, number: int
):
repository = f"{owner}/{repo}"
async def submit_requested_review():
if not await is_requested_review(repository, number):
raise HTTPException(status_code=404, detail="Review request not found")
return await gitea_proxy.submit_pull_review(
repository,
number,
submission.expected_head_sha,
submission.decision,
submission.body,
)
try:
result = await asyncio.wait_for(
submit_requested_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except gitea_proxy.StaleReviewError:
return JSONResponse(
{"error": "New commits were pushed. Refresh the review before submitting."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The review could not be submitted. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, status_code=201)
+48
View File
@@ -200,3 +200,51 @@ async def test_notification_detail_never_follows_foreign_api_urls():
assert result["url"] == ""
assert result["subject_body"] == ""
assert result["latest_comment"]["body"] == ""
@pytest.mark.anyio
@pytest.mark.parametrize("subject_kind", ["issues", "pulls"])
async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kind):
requests = []
def upstream(request):
requests.append((request.method, str(request.url), request.content))
if request.method == "GET":
return httpx.Response(200, json={
"id": 42,
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Pull" if subject_kind == "pulls" else "Issue",
"url": (
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/"
f"{subject_kind}/7"
),
},
})
return httpx.Response(201, json={
"id": 91,
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
})
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.reply_to_notification(42, "Please retry the worker.")
finally:
await gitea_proxy.stop_client()
assert requests == [
(
"GET",
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
b"",
),
(
"POST",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments",
b'{"body":"Please retry the worker."}',
),
]
assert result == {
"id": 91,
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
}
+158 -1
View File
@@ -596,6 +596,78 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
assert "notificationReader.markReadAndNext(lastMyWork)" in html
def test_notification_replier_preserves_failed_draft_and_clears_only_after_success():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
const statuses = [];
let calls = 0;
let fail = true;
const replier = buildMyWork.createNotificationReplier({{
storage,
post: async (id, body) => {{
calls += 1;
await new Promise(resolve => setTimeout(resolve, 5));
if (fail) throw new Error('offline');
return {{id:91, url:'https://forge.example/comment/91'}};
}},
onStatus: status => statuses.push(status),
}});
const item = {{notification_id:42}};
replier.saveDraft(item, 'Please retry.');
Promise.all([replier.submit(item, 'Please retry.'), replier.submit(item, 'Please retry.')])
.then(async first => {{
const afterFailure = replier.loadDraft(item);
fail = false;
const success = await replier.submit(item, afterFailure);
process.stdout.write(JSON.stringify({{
first, afterFailure, success, afterSuccess:replier.loadDraft(item), calls, statuses,
}}));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["first"] == [False, False]
assert output["afterFailure"] == "Please retry."
assert output["success"]["id"] == 91
assert output["afterSuccess"] == ""
assert output["calls"] == 2
assert output["statuses"] == [
"Sending reply…",
"Could not send reply. Your draft is safe; retry.",
"Sending reply…",
"Reply posted. You can mark this update read when ready.",
]
@pytest.mark.anyio
async def test_mobile_update_sheet_has_persistent_accessible_reply_composer():
html = await dashboard()
assert 'id="update-reply"' in html
assert 'maxlength="10000"' in html
assert 'id="send-update-reply"' in html
assert 'id="update-reply-status"' in html
assert 'aria-live="assertive"' in html
assert '.update-reply textarea { width:100%;' in html
assert '.update-reply button { min-height:44px;' in html
assert '.update-sheet-header button { min-height:44px;' in html
assert 'createNotificationReplier' in html
assert "method: 'POST'" in html
assert "'/reply'" in html
assert "notificationReplier.loadDraft(item)" in html
assert "notificationReplier.saveDraft(selectedUpdate" in html
assert "notificationReplier.submit(selectedUpdate" in html
@pytest.mark.anyio
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
html = await dashboard()
@@ -902,7 +974,12 @@ async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
assert "review-files" in html
assert "review-history" in html
assert "open-review-gitea" in html
assert "reviewController.submit" not in html
assert 'id="submit-review"' in html
assert 'id="review-submit-status"' in html and 'aria-live="assertive"' in html
assert "reviewController.submit(selectedReview" in html
assert "window.confirm" in html
assert "expected_head_sha: selectedReviewHead" in html
assert "await load()" in html
@pytest.mark.anyio
@@ -931,6 +1008,86 @@ async def test_mobile_review_sheet_captures_and_safely_hands_off_feedback():
assert "handoffWindow.opener = null" in html
def test_review_controller_submits_once_while_request_is_in_flight():
script = f"""
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
let resolveRequest;
const calls = [];
const controller = createReviewController({{ fetchJson: (url, options) => {{
calls.push({{url, options}});
return new Promise(resolve => {{ resolveRequest = resolve; }});
}} }});
const item = {{repository:'stackchain/api', number:7}};
const payload = {{decision:'approve', body:'Looks good.', expected_head_sha:'abc123'}};
const first = controller.submit(item, payload);
const second = controller.submit(item, payload);
resolveRequest({{id:91, state:'APPROVED'}});
Promise.all([first, second]).then(results => process.stdout.write(JSON.stringify({{
calls, results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
payload = json.loads(result.stdout)
assert len(payload["calls"]) == 1
assert payload["calls"][0] == {
"url": "api/v1/repos/stackchain/api/pulls/7/review",
"options": {
"method": "POST",
"headers": {
"Accept": "application/json",
"Content-Type": "application/json",
},
"body": json.dumps(
{
"decision": "approve",
"body": "Looks good.",
"expected_head_sha": "abc123",
},
separators=(",", ":"),
),
},
}
assert payload["results"] == [
{"id": 91, "state": "APPROVED"},
{"id": 91, "state": "APPROVED"},
]
def test_successful_review_can_clear_head_scoped_draft_and_progress():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const removed = [];
const storage = {{
getItem: key => values.get(key) || null,
setItem: (key, value) => values.set(key, value),
removeItem: key => {{ removed.push(key); values.delete(key); }},
}};
const options = {{storage, repository:'stackchain/api', number:7, headSha:'abc123', files:[{{filename:'a.py'}}]}};
const draft = reviewSheet.createDraft(options);
const progress = reviewSheet.createProgress(options);
draft.setSummary('Looks good.');
progress.markReviewed('a.py');
draft.clear();
progress.clear();
process.stdout.write(JSON.stringify({{removed, draft:draft.snapshot(), progress:progress.snapshot()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
payload = json.loads(result.stdout)
assert payload["removed"] == [
"stackchain.review-draft.v1:stackchain/api#7@abc123",
"stackchain.review-progress.v1:stackchain/api#7@abc123",
]
assert payload["draft"] == {"notes": {}, "summary": "", "decision": "comment"}
assert payload["progress"]["reviewedCount"] == 0
@pytest.mark.anyio
async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item():
html = await dashboard()
+90
View File
@@ -0,0 +1,90 @@
import asyncio
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_notification_reply_api_validates_and_posts_without_marking_read(monkeypatch):
calls = []
async def reply(thread_id, body):
calls.append((thread_id, body))
return {
"id": 91,
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
}
async def must_not_mark_read(_thread_id):
raise AssertionError("replying must not mark the update read")
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
monkeypatch.setattr(main, "mark_notification_read", must_not_mark_read)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/notifications/42/reply",
json={"body": " Please retry the worker. "},
)
empty = await client.post(
"/api/v1/notifications/42/reply", json={"body": " "}
)
oversized = await client.post(
"/api/v1/notifications/42/reply", json={"body": "x" * 10_001}
)
assert response.status_code == 201
assert response.json() == {
"id": 91,
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
}
assert response.headers["cache-control"] == "no-store"
assert empty.status_code == 422
assert oversized.status_code == 422
assert calls == [(42, "Please retry the worker.")]
@pytest.mark.anyio
@pytest.mark.parametrize("failure", ["timeout", "upstream"])
async def test_notification_reply_failure_is_sanitized_retryable_and_no_store(
monkeypatch, failure
):
async def reply(_thread_id, _body):
if failure == "timeout":
await asyncio.sleep(0.05)
raise httpx.HTTPError("token=secret upstream exploded")
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 0.01)
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/notifications/42/reply", json={"body": "Retry it"}
)
assert response.status_code == 503
assert response.json() == {
"error": "The reply could not be posted. Your draft is safe; please retry."
}
assert response.headers["retry-after"] == "1"
assert response.headers["cache-control"] == "no-store"
assert "secret" not in response.text
@pytest.mark.anyio
async def test_notification_reply_cannot_be_invoked_by_a_foreign_browser_origin():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.options(
"/api/v1/notifications/42/reply",
headers={
"Origin": "https://evil.example",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type",
},
)
assert response.status_code != 200
assert "access-control-allow-origin" not in response.headers
+193 -4
View File
@@ -3,7 +3,7 @@ import asyncio
import httpx
import pytest
from src import main
from src import gitea_proxy, main
@pytest.mark.anyio
@@ -76,13 +76,202 @@ async def test_review_detail_has_one_retryable_deadline_and_cancels_pending_work
@pytest.mark.anyio
async def test_public_review_endpoint_does_not_expose_service_token_mutations():
async def test_review_submission_posts_validated_decision_for_requested_current_head(monkeypatch):
calls = []
async def requested(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def submit(repository, number, expected_head_sha, decision, body):
calls.append((repository, number, expected_head_sha, decision, body))
return {"id": 91, "state": "APPROVED", "url": "https://forge.example/reviews/91"}
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit, raising=False)
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"},
json={
"decision": "approve",
"body": "Looks good on mobile.",
"expected_head_sha": "abc123",
},
)
assert response.status_code == 405
assert response.status_code == 201
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"id": 91,
"state": "APPROVED",
"url": "https://forge.example/reviews/91",
}
assert calls == [
("stackchain/api", 7, "abc123", "approve", "Looks good on mobile.")
]
@pytest.mark.anyio
async def test_gitea_review_submission_checks_head_then_maps_decision_upstream():
requests = []
async def handler(request):
requests.append(request)
if request.method == "GET":
return httpx.Response(200, json={"head": {"sha": "abc123"}})
return httpx.Response(
200,
json={
"id": 91,
"state": "APPROVED",
"html_url": "https://forge.example/reviews/91",
},
)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.submit_pull_review(
"stackchain/api", 7, "abc123", "approve", "Looks good."
)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("POST", "/api/v1/repos/stackchain/api/pulls/7/reviews"),
]
assert requests[1].content == b'{"body":"Looks good.","event":"APPROVE","commit_id":"abc123"}'
assert result == {
"id": 91,
"state": "APPROVED",
"url": "https://forge.example/reviews/91",
}
@pytest.mark.anyio
async def test_gitea_review_submission_rejects_changed_head_without_posting():
methods = []
async def handler(request):
methods.append(request.method)
return httpx.Response(200, json={"head": {"sha": "new456"}})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(gitea_proxy.StaleReviewError):
await gitea_proxy.submit_pull_review(
"stackchain/api", 7, "abc123", "request_changes", "Please revise."
)
finally:
await gitea_proxy.stop_client()
assert methods == ["GET"]
@pytest.mark.anyio
async def test_review_submission_returns_conflict_when_pull_head_changed(monkeypatch):
async def requested(repository, number):
return True
async def submit(*args):
raise gitea_proxy.StaleReviewError("changed")
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
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={
"decision": "request_changes",
"body": "Please revise.",
"expected_head_sha": "abc123",
},
)
assert response.status_code == 409
assert response.json() == {
"error": "New commits were pushed. Refresh the review before submitting."
}
@pytest.mark.anyio
async def test_review_submission_rejects_unsupported_decision_before_upstream(monkeypatch):
called = False
async def requested(repository, number):
nonlocal called
called = True
return True
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.post(
"/api/v1/repos/stackchain/api/pulls/7/review",
json={
"decision": "merge",
"body": "Ship it.",
"expected_head_sha": "abc123",
},
)
assert response.status_code == 422
assert called is False
@pytest.mark.anyio
async def test_review_submission_rejects_oversized_feedback_before_upstream(monkeypatch):
called = False
async def requested(repository, number):
nonlocal called
called = True
return True
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.post(
"/api/v1/repos/stackchain/api/pulls/7/review",
json={
"decision": "comment",
"body": "x" * 10_001,
"expected_head_sha": "abc123",
},
)
assert response.status_code == 422
assert called is False
@pytest.mark.anyio
async def test_review_submission_deadline_cancels_request_check(monkeypatch):
cancelled = asyncio.Event()
async def requested(repository, number):
try:
await asyncio.Event().wait()
finally:
cancelled.set()
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main, "REVIEW_DETAIL_TIMEOUT_SECONDS", 0.01)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await asyncio.wait_for(
client.post(
"/api/v1/repos/stackchain/api/pulls/7/review",
json={
"decision": "comment",
"body": "Review note.",
"expected_head_sha": "abc123",
},
),
timeout=0.2,
)
assert response.status_code == 503
assert response.headers["retry-after"] == "1"
assert cancelled.is_set()