feat: reply to unread updates in My Work (#153)
This commit is contained in:
parent
62ebabb132
commit
83de2a7499
|
|
@ -14,12 +14,14 @@ python3 -m pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
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
|
token that can read dashboard data, update the authenticated user's notification
|
||||||
notification threads, then start the API and bundled frontend:
|
threads, and create issue comments. Pull-request replies use Gitea's issue-comment
|
||||||
|
API. 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
|
```bash
|
||||||
export GITEA_URL='https://forge.example.com'
|
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
|
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.update-sheet.open { display:flex; }
|
.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-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 { 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-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 { 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 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; }
|
.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>
|
<summary><h2>Subject context</h2></summary>
|
||||||
<p class="update-sheet-content muted" id="update-subject-body"></p>
|
<p class="update-sheet-content muted" id="update-subject-body"></p>
|
||||||
</details>
|
</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">
|
<div class="update-sheet-actions">
|
||||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
<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>
|
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||||
|
|
@ -433,6 +444,17 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
return payload;
|
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({
|
const notificationAcknowledger = createNotificationAcknowledger({
|
||||||
markRead: markNotificationRead,
|
markRead: markNotificationRead,
|
||||||
onItems: items => {
|
onItems: items => {
|
||||||
|
|
@ -468,6 +490,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
},
|
},
|
||||||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
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({
|
const notificationReader = createNotificationReader({
|
||||||
load: fetchNotificationDetail,
|
load: fetchNotificationDetail,
|
||||||
markRead: markNotificationRead,
|
markRead: markNotificationRead,
|
||||||
|
|
@ -482,6 +509,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#update-subject-type').textContent = item.subject_type || 'Update';
|
qs('#update-subject-type').textContent = item.subject_type || 'Update';
|
||||||
qs('#update-subject-state').textContent = item.state || '';
|
qs('#update-subject-state').textContent = item.state || '';
|
||||||
qs('#open-update-gitea').href = item.url || '#';
|
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('#retry-update-load').hidden = true;
|
||||||
qs('#keep-update-unread').focus();
|
qs('#keep-update-unread').focus();
|
||||||
},
|
},
|
||||||
|
|
@ -910,6 +940,27 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#retry-update-load').addEventListener('click', () => {
|
qs('#retry-update-load').addEventListener('click', () => {
|
||||||
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
|
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').addEventListener('click', async () => {
|
||||||
qs('#mark-update-read-next').disabled = true;
|
qs('#mark-update-read-next').disabled = true;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
function filterMyWork(items, selectedFilter) {
|
||||||
if (selectedFilter === 'all') return items;
|
if (selectedFilter === 'all') return items;
|
||||||
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
|
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
|
||||||
|
|
@ -263,5 +296,6 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||||
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
||||||
buildMyWork.createNotificationPager = createNotificationPager;
|
buildMyWork.createNotificationPager = createNotificationPager;
|
||||||
buildMyWork.createNotificationReader = createNotificationReader;
|
buildMyWork.createNotificationReader = createNotificationReader;
|
||||||
|
buildMyWork.createNotificationReplier = createNotificationReplier;
|
||||||
module.exports = buildMyWork;
|
module.exports = buildMyWork;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
@ -279,6 +280,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]:
|
async def pull_requests() -> list[dict]:
|
||||||
assigned, review_requested = await asyncio.gather(
|
assigned, review_requested = await asyncio.gather(
|
||||||
fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"),
|
fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"),
|
||||||
|
|
|
||||||
45
src/main.py
45
src/main.py
|
|
@ -5,10 +5,9 @@ from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
|
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
|
||||||
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 pydantic import BaseModel, Field, PositiveInt
|
from pydantic import BaseModel, Field, PositiveInt, field_validator
|
||||||
|
|
||||||
from src import gitea_proxy
|
from src import gitea_proxy
|
||||||
from src.gitea_proxy import (
|
from src.gitea_proxy import (
|
||||||
|
|
@ -82,6 +81,18 @@ class NotificationReadBatch(BaseModel):
|
||||||
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
|
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
|
||||||
|
|
||||||
|
|
||||||
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||||
user_model = User(
|
user_model = User(
|
||||||
id=user_data["id"],
|
id=user_data["id"],
|
||||||
|
|
@ -127,14 +138,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()
|
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.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||||
app.include_router(frontend_router)
|
app.include_router(frontend_router)
|
||||||
|
|
||||||
|
|
@ -626,6 +629,28 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||||
return JSONResponse({"id": thread_id, "status": "read"})
|
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")
|
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review")
|
||||||
async def review_detail(owner: str, repo: str, number: int):
|
async def review_detail(owner: str, repo: str, number: int):
|
||||||
async def load_requested_review():
|
async def load_requested_review():
|
||||||
|
|
|
||||||
|
|
@ -200,3 +200,51 @@ async def test_notification_detail_never_follows_foreign_api_urls():
|
||||||
assert result["url"] == ""
|
assert result["url"] == ""
|
||||||
assert result["subject_body"] == ""
|
assert result["subject_body"] == ""
|
||||||
assert result["latest_comment"]["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",
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -596,6 +596,78 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
|
||||||
assert "notificationReader.markReadAndNext(lastMyWork)" in html
|
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
|
@pytest.mark.anyio
|
||||||
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
|
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
90
tests/test_notification_reply.py
Normal file
90
tests/test_notification_reply.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user