feat: read full unread update conversations (#219)
All checks were successful
CI / lint (pull_request) Successful in 20s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy 2026-08-07 18:48:37 +00:00
parent 3824ff180a
commit ea909ef222
8 changed files with 379 additions and 20 deletions

View File

@ -548,9 +548,10 @@ textarea { resize: vertical; min-height: 120px; }
<span class="pill" id="update-subject-type">Update</span> <span class="pill" id="update-subject-type">Update</span>
<span class="pill" id="update-subject-state"></span> <span class="pill" id="update-subject-state"></span>
</div> </div>
<h2>Latest comment</h2> <h2>Full conversation</h2>
<div class="small" id="update-comment-meta"></div> <div id="update-comments"></div>
<p class="update-sheet-content" id="update-comment-body"></p> <button class="conversation-more" id="load-older-update-comments" type="button" hidden>Load older messages</button>
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
<details> <details>
<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>
@ -845,6 +846,16 @@ textarea { resize: vertical; min-height: 120px; }
return payload; return payload;
} }
async function fetchNotificationConversation(notificationId, page) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
'/conversation?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading older messages failed.');
return payload;
}
async function fetchWorkPage(stream, page) { async function fetchWorkPage(stream, page) {
const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), { const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
@ -924,14 +935,16 @@ textarea { resize: vertical; min-height: 120px; }
}); });
const notificationReader = createNotificationReader({ const notificationReader = createNotificationReader({
load: fetchNotificationDetail, load: fetchNotificationDetail,
loadConversation: fetchNotificationConversation,
markRead: markNotificationRead, markRead: markNotificationRead,
onOpen: item => { onOpen: item => {
selectedUpdate = item; selectedUpdate = item;
qs('#update-sheet').classList.add('open'); qs('#update-sheet').classList.add('open');
qs('#update-sheet-key').textContent = item.key || ''; qs('#update-sheet-key').textContent = item.key || '';
qs('#update-sheet-title').textContent = item.title || 'Unread update'; qs('#update-sheet-title').textContent = item.title || 'Unread update';
qs('#update-comment-meta').textContent = ''; qs('#update-comments').textContent = '';
qs('#update-comment-body').textContent = ''; qs('#update-conversation-status').textContent = '';
qs('#load-older-update-comments').hidden = true;
qs('#update-subject-body').textContent = ''; qs('#update-subject-body').textContent = '';
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 || '';
@ -943,18 +956,14 @@ textarea { resize: vertical; min-height: 120px; }
qs('#keep-update-unread').focus(); qs('#keep-update-unread').focus();
}, },
onDetail: detail => { onDetail: detail => {
const comment = detail.latest_comment || {};
qs('#update-sheet-title').textContent = detail.title || 'Unread update'; qs('#update-sheet-title').textContent = detail.title || 'Unread update';
qs('#update-subject-type').textContent = detail.subject_type || 'Update'; qs('#update-subject-type').textContent = detail.subject_type || 'Update';
qs('#update-subject-state').textContent = detail.state || ''; qs('#update-subject-state').textContent = detail.state || '';
qs('#update-comment-meta').textContent = comment.author ?
comment.author + (comment.created_at ? ' · ' + fmt(comment.created_at) : '') :
(comment.created_at ? fmt(comment.created_at) : 'No comment author reported');
qs('#update-comment-body').textContent = comment.body || 'No comment body was provided.';
qs('#update-subject-body').textContent = detail.subject_body || 'No subject context was provided.'; qs('#update-subject-body').textContent = detail.subject_body || 'No subject context was provided.';
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#'; qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
qs('#retry-update-load').hidden = true; qs('#retry-update-load').hidden = true;
}, },
onConversation: renderUpdateConversation,
onItems: items => { onItems: items => {
const readId = selectedUpdate?.notification_id; const readId = selectedUpdate?.notification_id;
lastMyWork = items; lastMyWork = items;
@ -1365,6 +1374,15 @@ textarea { resize: vertical; min-height: 120px; }
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.'; comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
} }
function renderUpdateConversation(state) {
const comments = state?.comments || [];
qs('#update-comments').innerHTML = comments.length ?
comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-update-comments').hidden = !Number.isInteger(state?.older_page);
qs('#update-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
function renderPullConversation(state) { function renderPullConversation(state) {
const comments = state?.comments || []; const comments = state?.comments || [];
qs('#pull-comments').innerHTML = comments.length ? comments.map(comment => qs('#pull-comments').innerHTML = comments.length ? comments.map(comment =>
@ -2713,6 +2731,16 @@ 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('#load-older-update-comments').addEventListener('click', async () => {
const button = qs('#load-older-update-comments');
const panel = qs('#update-sheet .update-sheet-panel');
const previousHeight = panel.scrollHeight;
const previousTop = panel.scrollTop;
button.disabled = true;
await notificationReader.loadOlder();
panel.scrollTop = previousTop + (panel.scrollHeight - previousHeight);
button.disabled = false;
});
qs('#update-reply').addEventListener('input', event => { qs('#update-reply').addEventListener('input', event => {
if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value); if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value);
}); });
@ -2728,6 +2756,7 @@ textarea { resize: vertical; min-height: 120px; }
const result = await notificationReplier.submit(selectedUpdate, body); const result = await notificationReplier.submit(selectedUpdate, body);
qs('#send-update-reply').disabled = false; qs('#send-update-reply').disabled = false;
if (result) { if (result) {
notificationReader.appendReply(result);
qs('#update-reply').value = ''; qs('#update-reply').value = '';
qs('#mark-update-read-next').focus(); qs('#mark-update-read-next').focus();
} else { } else {

View File

@ -249,10 +249,16 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
}; };
} }
function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose }) { function createNotificationReader({
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
loadConversation = null,
onConversation = () => {},
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
}) {
let selected = null; let selected = null;
let loadVersion = 0; let loadVersion = 0;
let marking = false; let marking = false;
let conversationPager = null;
async function open(item) { async function open(item) {
selected = item; selected = item;
@ -263,6 +269,14 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
const detail = await load(item.notification_id); const detail = await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false; if (selected !== item || version !== loadVersion) return false;
onDetail(detail); onDetail(detail);
if (createPager && loadConversation && detail.conversation) {
conversationPager = createPager({
loadPage: page => loadConversation(item.notification_id, page),
});
onConversation(conversationPager.reset(detail.conversation));
} else {
conversationPager = null;
}
onStatus('Update ready.'); onStatus('Update ready.');
return true; return true;
} catch (_error) { } catch (_error) {
@ -275,6 +289,29 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
return { return {
open, open,
appendReply(comment) {
if (!conversationPager) return false;
onConversation(conversationPager.append(comment));
return true;
},
async loadOlder() {
if (!conversationPager || !selected) return false;
const pager = conversationPager;
const version = loadVersion;
onStatus('Loading older messages…');
try {
const state = await pager.loadOlder();
if (pager !== conversationPager || version !== loadVersion) return false;
onConversation(state);
onStatus(state.comments.length + ' of ' + state.total + ' messages loaded.');
return true;
} catch (_error) {
if (pager === conversationPager && version === loadVersion) {
onStatus('Could not load older messages. Retry.');
}
return false;
}
},
async markReadAndNext(items) { async markReadAndNext(items) {
if (!selected || marking) return false; if (!selected || marking) return false;
const current = selected; const current = selected;

View File

@ -533,8 +533,30 @@ async def notification_detail(thread_id: int) -> dict:
subject = subject_value if isinstance(subject_value, dict) else {} subject = subject_value if isinstance(subject_value, dict) else {}
subject_path = _gitea_api_path(subject.get("url")) subject_path = _gitea_api_path(subject.get("url"))
comment_path = _gitea_api_path(subject.get("latest_comment_url")) comment_path = _gitea_api_path(subject.get("latest_comment_url"))
subject_detail = await fetch(subject_path) if subject_path else {} conversation_match = re.fullmatch(
comment = await fetch(comment_path) if comment_path else {} r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
)
repository_name = repository.get("full_name")
supported_conversation = (
conversation_match
and conversation_match.group(1) == repository_name
and subject.get("type") in {"Issue", "Pull"}
)
if supported_conversation:
assert conversation_match is not None
subject_detail, comment, conversation = await asyncio.gather(
fetch(subject_path),
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
issue_conversation_page(
conversation_match.group(1), int(conversation_match.group(3))
),
)
else:
subject_detail, comment, conversation = await asyncio.gather(
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
)
subject_detail = subject_detail if isinstance(subject_detail, dict) else {} subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
comment = comment if isinstance(comment, dict) else {} comment = comment if isinstance(comment, dict) else {}
user_value = comment.get("user") user_value = comment.get("user")
@ -573,9 +595,33 @@ async def notification_detail(thread_id: int) -> dict:
else "", else "",
"url": latest_url, "url": latest_url,
}, },
"conversation": conversation,
} }
async def notification_conversation_page(
thread_id: int, page: int, limit: int = 20
) -> 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")
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.get("full_name")
or subject.get("type") not in {"Issue", "Pull"}
):
raise ValueError("Notification subject is not a supported conversation")
return await issue_conversation_page(
match.group(1), int(match.group(3)), page=page, limit=limit
)
async def reply_to_notification(thread_id: int, body: str) -> dict: async def reply_to_notification(thread_id: int, body: str) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}") thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict): if not isinstance(thread, dict):
@ -604,10 +650,7 @@ async def reply_to_notification(thread_id: int, body: str) -> dict:
comment = response.json() comment = response.json()
if not isinstance(comment, dict): if not isinstance(comment, dict):
raise ValueError("Gitea comment response was not an object") raise ValueError("Gitea comment response was not an object")
return { return _normalize_issue_comment(comment)
"id": comment.get("id"),
"url": _safe_web_url(comment.get("html_url")),
}
async def close_issue(repository: str, number: int) -> dict: async def close_issue(repository: str, number: int) -> dict:

View File

@ -1095,6 +1095,32 @@ async def notification_thread_detail(
return JSONResponse(result) return JSONResponse(result)
@app.get("/api/v1/notifications/{thread_id}/conversation")
async def notification_thread_conversation(
thread_id: int = PathParam(gt=0),
page: int = Query(ge=1),
limit: int = Query(default=20, ge=1, le=50),
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.notification_conversation_page(thread_id, page, limit),
timeout=NOTIFICATION_DETAIL_TIMEOUT_SECONDS,
)
except TimeoutError:
return JSONResponse(
{"error": "Loading older messages timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "Older messages are temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/notifications/{thread_id}/read") @app.patch("/api/v1/notifications/{thread_id}/read")
async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse: async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try: try:

View File

@ -136,11 +136,20 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
return httpx.Response(200, json={"body": "Deploy fails after **three** retries."}) return httpx.Response(200, json={"body": "Deploy fails after **three** retries."})
if request.url.path.endswith("/issues/comments/9"): if request.url.path.endswith("/issues/comments/9"):
return httpx.Response(200, json={ return httpx.Response(200, json={
"id": 9,
"body": "Logs point to the worker timeout.", "body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z", "created_at": "2026-08-06T12:30:00Z",
"user": {"login": "alexander"}, "user": {"login": "alexander"},
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", "html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
}) })
if request.url.path.endswith("/issues/7/comments"):
return httpx.Response(200, json=[{
"id": 9,
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"user": {"login": "alexander"},
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
}], headers={"X-Total-Count": "1"})
return httpx.Response(404) return httpx.Response(404)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream)) gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
@ -153,6 +162,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"http://127.0.0.1:3000/api/v1/notifications/threads/42", "http://127.0.0.1:3000/api/v1/notifications/threads/42",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7", "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9", "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
] ]
assert result == { assert result == {
"id": 42, "id": 42,
@ -168,6 +178,18 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"created_at": "2026-08-06T12:30:00Z", "created_at": "2026-08-06T12:30:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9", "url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
}, },
"conversation": {
"comments": [{
"id": 9,
"author": "alexander",
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
}],
"page": 1,
"older_page": None,
"total": 1,
},
} }
@ -223,6 +245,9 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
}) })
return httpx.Response(201, json={ return httpx.Response(201, json={
"id": 91, "id": 91,
"user": {"login": "timmy"},
"body": "Please retry the worker.",
"created_at": "2026-08-07T19:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91", "html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
}) })
@ -246,5 +271,8 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
] ]
assert result == { assert result == {
"id": 91, "id": 91,
"author": "timmy",
"body": "Please retry the worker.",
"created_at": "2026-08-07T19:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91", "url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
} }

View File

@ -1619,6 +1619,72 @@ reader.open(item, [item]).then(() =>
} }
def test_notification_reader_pages_conversation_single_flight_and_ignores_stale_update():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const details = {{
42: {{id:42, conversation:{{comments:[{{id:41,created_at:'2026-08-07T12:41:00Z'}}],page:3,older_page:2,total:47}}}},
43: {{id:43, conversation:{{comments:[{{id:90,created_at:'2026-08-07T13:00:00Z'}}],page:1,older_page:null,total:1}}}},
}};
let release;
const requested = [];
const states = [];
const reader = build.createNotificationReader({{
load: async id => details[id], createPager,
loadConversation: (id, page) => {{
requested.push([id, page]);
return new Promise(resolve => {{ release = () => resolve({{comments:[{{id:21,created_at:'2026-08-07T12:21:00Z'}}],page:2,older_page:1,total:47}}); }});
}},
markRead: async () => {{}}, onOpen: () => {{}}, onDetail: () => {{}},
onConversation: state => states.push(state.comments.map(item => item.id)),
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
}});
const firstItem = {{notification_id:42}};
const secondItem = {{notification_id:43}};
reader.open(firstItem).then(async () => {{
const first = reader.loadOlder();
const duplicate = reader.loadOlder();
await reader.open(secondItem);
release();
await Promise.all([first, duplicate]);
process.stdout.write(JSON.stringify({{requested, states}}));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["requested"] == [[42, 2]]
assert output["states"] == [[41], [90]]
def test_notification_reader_appends_a_confirmed_reply_exactly_once():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const states = [];
const reader = build.createNotificationReader({{
load: async () => ({{conversation:{{comments:[{{id:41}}],page:1,older_page:null,total:1}}}}),
loadConversation: async () => ({{}}), createPager,
markRead: async () => {{}}, onOpen: () => {{}}, onDetail: () => {{}},
onConversation: state => states.push(state.comments.map(item => item.id)),
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
}});
reader.open({{notification_id:42}}).then(() => {{
reader.appendReply({{id:91, author:'timmy', body:'Ship it'}});
reader.appendReply({{id:91, author:'timmy', body:'Ship it'}});
process.stdout.write(JSON.stringify(states));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == [[41], [41, 91], [41, 91]]
def test_notification_reader_wraps_to_an_earlier_visible_unread_update(): def test_notification_reader_wraps_to_an_earlier_visible_unread_update():
script = f""" script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))}); const buildMyWork = require({json.dumps(str(MY_WORK))});
@ -2283,7 +2349,7 @@ async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
assert 'id="keep-update-unread"' in html assert 'id="keep-update-unread"' in html
assert 'id="mark-update-read-next"' in html assert 'id="mark-update-read-next"' in html
assert 'id="retry-update-load"' in html assert 'id="retry-update-load"' in html
assert 'id="update-comment-body"' in html assert 'id="update-comments"' in html
assert 'id="update-subject-body"' in html assert 'id="update-subject-body"' in html
assert 'id="open-update-gitea"' in html assert 'id="open-update-gitea"' in html
assert '.update-sheet-panel { width:min(560px,100%);' in html assert '.update-sheet-panel { width:min(560px,100%);' in html
@ -2882,6 +2948,23 @@ async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item(
assert "qs('#retry-review-load').focus()" in html assert "qs('#retry-review-load').focus()" in html
@pytest.mark.anyio
async def test_mobile_update_sheet_renders_and_pages_the_complete_conversation():
html = await dashboard()
update_sheet = html[html.index('id="update-sheet"'):html.index('id="pull-sheet"')]
assert '<h2>Full conversation</h2>' in update_sheet
assert 'id="update-comments"' in update_sheet
assert 'id="load-older-update-comments"' in update_sheet
assert 'id="update-conversation-status"' in update_sheet
assert '.conversation-more' in html and 'min-height:44px' in html
assert "'/conversation?page='" in html
assert "loadConversation: fetchNotificationConversation" in html
assert "onConversation: renderUpdateConversation" in html
assert "notificationReader.loadOlder()" in html
assert "notificationReader.appendReply(result)" in html
def test_issue_comment_reuses_operation_key_after_reload_until_success(): def test_issue_comment_reuses_operation_key_after_reload_until_success():
script = f""" script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))}); const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});

View File

@ -3,7 +3,59 @@ import asyncio
import httpx import httpx
import pytest import pytest
from src import main from src import gitea_proxy, main
@pytest.mark.anyio
async def test_notification_detail_opens_the_newest_conversation_page_in_chronological_order(monkeypatch):
requested_pages = []
def comment(comment_id):
return {
"id": comment_id,
"user": {"login": f"user-{comment_id}"},
"body": f"message {comment_id}",
"created_at": f"2026-08-06T12:{comment_id:02d}:00Z",
"html_url": f"https://forge.example/stackchain/api/issues/7#issuecomment-{comment_id}",
}
def handler(request):
if request.url.path.endswith("/notifications/threads/42"):
return httpx.Response(200, json={
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Issue", "title": "Retry failed deploy", "state": "open",
"url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
"latest_comment_url": "https://forge.example/api/v1/repos/stackchain/api/issues/comments/47",
"html_url": "https://forge.example/stackchain/api/issues/7",
},
})
if request.url.path.endswith("/repos/stackchain/api/issues/7/comments"):
page = int(request.url.params["page"])
requested_pages.append(page)
comments = [comment(i) for i in (range(1, 21) if page == 1 else range(41, 48))]
return httpx.Response(200, json=comments, headers={"X-Total-Count": "47"})
if request.url.path.endswith("/repos/stackchain/api/issues/7"):
return httpx.Response(200, json={"body": "Deploy fails after retries."})
if request.url.path.endswith("/repos/stackchain/api/issues/comments/47"):
return httpx.Response(200, json=comment(47))
raise AssertionError(f"unexpected request: {request.url}")
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.notification_detail(42)
finally:
await gitea_proxy.stop_client()
assert requested_pages == [1, 3]
assert [item["id"] for item in result["conversation"]["comments"]] == list(range(41, 48))
assert result["conversation"] == {
"comments": result["conversation"]["comments"],
"page": 3,
"older_page": 2,
"total": 47,
}
@pytest.mark.anyio @pytest.mark.anyio
@ -41,6 +93,32 @@ async def test_notification_detail_api_is_bounded_and_never_cacheable(monkeypatc
assert calls == [42] assert calls == [42]
@pytest.mark.anyio
async def test_notification_conversation_api_loads_one_bounded_older_page(monkeypatch):
calls = []
async def conversation(thread_id, page, limit):
calls.append((thread_id, page, limit))
return {
"comments": [{"id": 21, "author": "timmy", "body": "Earlier context"}],
"page": page,
"older_page": 1,
"total": 47,
}
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_page", conversation, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/42/conversation?page=2&limit=20")
invalid = await client.get("/api/v1/notifications/42/conversation?page=0&limit=51")
assert response.status_code == 200
assert response.json()["comments"][0]["id"] == 21
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert calls == [(42, 2, 20)]
@pytest.mark.anyio @pytest.mark.anyio
async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch): async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch):
async def detail(_thread_id): async def detail(_thread_id):

View File

@ -3,7 +3,42 @@ import asyncio
import httpx import httpx
import pytest import pytest
from src import main from src import gitea_proxy, main
@pytest.mark.anyio
async def test_notification_reply_returns_the_confirmed_comment_for_exact_once_append(monkeypatch):
def handler(request):
if request.method == "GET":
return httpx.Response(200, json={
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Issue",
"url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
},
})
return httpx.Response(201, json={
"id": 91,
"user": {"login": "timmy"},
"body": "Please retry the worker.",
"created_at": "2026-08-07T19:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
})
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.reply_to_notification(42, "Please retry the worker.")
finally:
await gitea_proxy.stop_client()
assert result == {
"id": 91,
"author": "timmy",
"body": "Please retry the worker.",
"created_at": "2026-08-07T19:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
}
@pytest.mark.anyio @pytest.mark.anyio