Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30a014b619 | ||
|
|
9b81dce8aa | ||
|
|
7f5e671f8d | ||
|
|
83de2a7499 | ||
|
|
62ebabb132 |
No files matched your search
@@ -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
@@ -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 & 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 & 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user