feat: read full unread update conversations (#219)
This commit is contained in:
parent
3824ff180a
commit
ea909ef222
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
26
src/main.py
26
src/main.py
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))});
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user