diff --git a/frontend/index.html b/frontend/index.html
index 1d47425..ee9cece 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -548,9 +548,10 @@ textarea { resize: vertical; min-height: 120px; }
Update
-
Latest comment
-
-
+ Full conversation
+
+
+
Subject context
@@ -845,6 +846,16 @@ textarea { resize: vertical; min-height: 120px; }
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) {
const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), {
headers: { Accept: 'application/json' },
@@ -924,14 +935,16 @@ textarea { resize: vertical; min-height: 120px; }
});
const notificationReader = createNotificationReader({
load: fetchNotificationDetail,
+ loadConversation: fetchNotificationConversation,
markRead: markNotificationRead,
onOpen: item => {
selectedUpdate = item;
qs('#update-sheet').classList.add('open');
qs('#update-sheet-key').textContent = item.key || '';
qs('#update-sheet-title').textContent = item.title || 'Unread update';
- qs('#update-comment-meta').textContent = '';
- qs('#update-comment-body').textContent = '';
+ qs('#update-comments').textContent = '';
+ qs('#update-conversation-status').textContent = '';
+ qs('#load-older-update-comments').hidden = true;
qs('#update-subject-body').textContent = '';
qs('#update-subject-type').textContent = item.subject_type || 'Update';
qs('#update-subject-state').textContent = item.state || '';
@@ -943,18 +956,14 @@ textarea { resize: vertical; min-height: 120px; }
qs('#keep-update-unread').focus();
},
onDetail: detail => {
- const comment = detail.latest_comment || {};
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
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('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
qs('#retry-update-load').hidden = true;
},
+ onConversation: renderUpdateConversation,
onItems: items => {
const readId = selectedUpdate?.notification_id;
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.';
}
+ function renderUpdateConversation(state) {
+ const comments = state?.comments || [];
+ qs('#update-comments').innerHTML = comments.length ?
+ comments.map(renderIssueComment).join('') : 'No comments yet.
';
+ 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) {
const comments = state?.comments || [];
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', () => {
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 => {
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);
qs('#send-update-reply').disabled = false;
if (result) {
+ notificationReader.appendReply(result);
qs('#update-reply').value = '';
qs('#mark-update-read-next').focus();
} else {
diff --git a/frontend/my-work.js b/frontend/my-work.js
index 39f4988..c1c08a2 100644
--- a/frontend/my-work.js
+++ b/frontend/my-work.js
@@ -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 loadVersion = 0;
let marking = false;
+ let conversationPager = null;
async function open(item) {
selected = item;
@@ -263,6 +269,14 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
const detail = await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false;
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.');
return true;
} catch (_error) {
@@ -275,6 +289,29 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
return {
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) {
if (!selected || marking) return false;
const current = selected;
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index bb26042..5a7f9a5 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -533,8 +533,30 @@ async def notification_detail(thread_id: int) -> dict:
subject = subject_value if isinstance(subject_value, dict) else {}
subject_path = _gitea_api_path(subject.get("url"))
comment_path = _gitea_api_path(subject.get("latest_comment_url"))
- subject_detail = await fetch(subject_path) if subject_path else {}
- comment = await fetch(comment_path) if comment_path else {}
+ conversation_match = re.fullmatch(
+ 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 {}
comment = comment if isinstance(comment, dict) else {}
user_value = comment.get("user")
@@ -573,9 +595,33 @@ async def notification_detail(thread_id: int) -> dict:
else "",
"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:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
@@ -604,10 +650,7 @@ async def reply_to_notification(thread_id: int, body: str) -> dict:
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")),
- }
+ return _normalize_issue_comment(comment)
async def close_issue(repository: str, number: int) -> dict:
diff --git a/src/main.py b/src/main.py
index 1dc1e32..cace81e 100644
--- a/src/main.py
+++ b/src/main.py
@@ -1095,6 +1095,32 @@ async def notification_thread_detail(
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")
async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try:
diff --git a/tests/test_gitea_notifications.py b/tests/test_gitea_notifications.py
index f51e351..255826c 100644
--- a/tests/test_gitea_notifications.py
+++ b/tests/test_gitea_notifications.py
@@ -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."})
if request.url.path.endswith("/issues/comments/9"):
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",
})
+ 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)
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/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/7/comments?limit=20&page=1",
]
assert result == {
"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",
"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={
"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",
})
@@ -246,5 +271,8 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
]
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",
}
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 2786b57..95fef44 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -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():
script = f"""
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="mark-update-read-next"' 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="open-update-gitea"' 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
+@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 'Full conversation
' 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():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
diff --git a/tests/test_notification_detail.py b/tests/test_notification_detail.py
index 1f963d7..d2974a5 100644
--- a/tests/test_notification_detail.py
+++ b/tests/test_notification_detail.py
@@ -3,7 +3,59 @@ import asyncio
import httpx
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
@@ -41,6 +93,32 @@ async def test_notification_detail_api_is_bounded_and_never_cacheable(monkeypatc
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
async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch):
async def detail(_thread_id):
diff --git a/tests/test_notification_reply.py b/tests/test_notification_reply.py
index a78d062..fc972ba 100644
--- a/tests/test_notification_reply.py
+++ b/tests/test_notification_reply.py
@@ -3,7 +3,42 @@ import asyncio
import httpx
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