feat: load complete mobile conversations (#209)
This commit is contained in:
1 parent
e04d6753ac
commit
c63b23865c
9 files changed
+528
-30
No files matched your search
@@ -912,6 +912,44 @@ async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkey
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no_store(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
calls.append(("assigned", repository, number))
|
||||
return repository == "stackchain/api"
|
||||
|
||||
async def conversation(repository, number, page, limit):
|
||||
calls.append(("conversation", repository, number, page, limit))
|
||||
return {"comments": [{"id": 21}], "page": 2, "older_page": 1, "total": 21}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments?page=2&limit=20"
|
||||
)
|
||||
missing = await client.get(
|
||||
"/api/v1/repos/private/secret/issues/7/comments?page=2&limit=20"
|
||||
)
|
||||
invalid = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments?page=999&limit=999"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["older_page"] == 1
|
||||
assert missing.status_code == 404
|
||||
assert invalid.status_code == 422
|
||||
assert calls == [
|
||||
("assigned", "stackchain/api", 7),
|
||||
("conversation", "stackchain/api", 7, 2, 20),
|
||||
("assigned", "private/secret", 7),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
@@ -1157,9 +1195,79 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
|
||||
}
|
||||
],
|
||||
"conversation": {
|
||||
"comments": [
|
||||
{
|
||||
"id": 81,
|
||||
"author": "sam",
|
||||
"body": "Latest update",
|
||||
"created_at": "2026-08-07T10:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"older_page": None,
|
||||
"total": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_conversation_page_opens_newest_page_and_reports_older_history():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.url.path, request.url.query.decode()))
|
||||
page = request.url.params.get("page")
|
||||
comments = {
|
||||
"1": [{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
|
||||
for value in range(1, 21)],
|
||||
"3": [{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
|
||||
for value in range(41, 48)],
|
||||
}[page]
|
||||
return httpx.Response(200, json=comments, headers={"X-Total-Count": "47"})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.issue_conversation_page("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
("/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
|
||||
("/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=3"),
|
||||
]
|
||||
assert [comment["id"] for comment in result["comments"]] == list(range(41, 48))
|
||||
assert result["page"] == 3
|
||||
assert result["older_page"] == 2
|
||||
assert result["total"] == 47
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_conversation_page_locally_bounds_unpaginated_gitea_comments():
|
||||
requests = []
|
||||
all_comments = [
|
||||
{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
|
||||
for value in range(1, 48)
|
||||
]
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request.url.query.decode())
|
||||
return httpx.Response(200, json=all_comments, headers={"X-Total-Count": "47"})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.issue_conversation_page("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == ["limit=20&page=1"]
|
||||
assert [comment["id"] for comment in result["comments"]] == list(range(41, 48))
|
||||
assert result["page"] == 3
|
||||
assert result["older_page"] == 2
|
||||
assert result["total"] == 47
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_assigned_issue_labels_validates_and_returns_confirmed_labels(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -12,6 +12,7 @@ REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
||||
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
|
||||
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||
|
||||
@@ -1569,6 +1570,100 @@ reader.open(item, [item]).then(() => {{
|
||||
]
|
||||
|
||||
|
||||
def test_conversation_pager_prepends_older_pages_deduplicates_and_appends_once():
|
||||
script = f"""
|
||||
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
||||
let calls = 0;
|
||||
const pager = createConversationPager({{
|
||||
loadPage: async page => {{ calls += 1; return {{
|
||||
comments:[{{id:20,body:'duplicate'}},{{id:1,body:'oldest'}}],
|
||||
page, older_page:null, total:3
|
||||
}}; }}
|
||||
}});
|
||||
pager.reset({{comments:[{{id:20,body:'middle'}},{{id:21,body:'newest'}}],page:2,older_page:1,total:3}});
|
||||
const first = pager.loadOlder();
|
||||
const duplicate = pager.loadOlder();
|
||||
Promise.all([first, duplicate]).then(() => {{
|
||||
pager.append({{id:22,body:'posted'}});
|
||||
pager.append({{id:22,body:'posted'}});
|
||||
process.stdout.write(JSON.stringify({{calls,same:first===duplicate,state:pager.snapshot()}}));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["calls"] == 1
|
||||
assert output["same"] is True
|
||||
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 20, 21, 22]
|
||||
assert output["state"]["older_page"] is None
|
||||
assert output["state"]["total"] == 4
|
||||
|
||||
|
||||
def test_conversation_pager_keeps_loaded_messages_when_older_page_fails():
|
||||
script = f"""
|
||||
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
||||
const pager = createConversationPager({{loadPage: async () => {{ throw new Error('offline'); }}}});
|
||||
pager.reset({{comments:[{{id:21,body:'newest'}}],page:2,older_page:1,total:21}});
|
||||
pager.loadOlder().catch(error => process.stdout.write(JSON.stringify({{
|
||||
error:error.message, state:pager.snapshot()
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["error"] == "offline"
|
||||
assert output["state"]["comments"] == [{"id": 21, "body": "newest"}]
|
||||
assert output["state"]["older_page"] == 1
|
||||
|
||||
|
||||
def test_issue_sheet_conversation_loads_older_history_through_assigned_boundary():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
||||
const calls = [];
|
||||
const controller = createIssueSheet({{
|
||||
createConversationPager,
|
||||
fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
|
||||
}});
|
||||
const pager = controller.conversation(
|
||||
{{repository:'stackchain/api',number:7}},
|
||||
{{comments:[{{id:21}}],page:2,older_page:1,total:21}}
|
||||
);
|
||||
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["calls"] == ["api/v1/repos/stackchain/api/issues/7/comments?page=1&limit=20"]
|
||||
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]
|
||||
|
||||
|
||||
def test_pull_sheet_conversation_loads_older_history_through_assigned_boundary():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const createConversationPager = require({json.dumps(str(CONVERSATION))});
|
||||
const calls = [];
|
||||
const controller = createPullSheet({{
|
||||
createConversationPager,
|
||||
fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
|
||||
}});
|
||||
const pager = controller.conversation(
|
||||
{{repository:'stackchain/api',number:7}},
|
||||
{{comments:[{{id:21}}],page:2,older_page:1,total:21}}
|
||||
);
|
||||
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["calls"] == ["api/v1/repos/stackchain/api/pulls/7/comments?page=1&limit=20"]
|
||||
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]
|
||||
|
||||
|
||||
def test_issue_sheet_loads_encoded_assigned_issue_detail_path():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
@@ -1860,6 +1955,12 @@ async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
|
||||
assert 'id="pull-files"' in html and 'id="pull-comments"' in html
|
||||
assert 'id="pull-review-progress"' in html and 'aria-live="polite"' in html
|
||||
assert 'id="next-unreviewed-pull-file"' in html
|
||||
assert 'id="load-older-pull-comments"' in html
|
||||
assert 'id="pull-conversation-status"' in html and 'aria-live="assertive"' in html
|
||||
assert '<script src="static/conversation.js"></script>' in html
|
||||
assert "pullController.conversation(item, detail.conversation)" in html
|
||||
assert "pullConversation.loadOlder()" in html
|
||||
assert "pullConversation.append(comment)" in html
|
||||
assert 'id="pull-comment"' in html and 'maxlength="10000"' in html
|
||||
assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html
|
||||
assert '<script src="static/pull-sheet.js"></script>' in html
|
||||
@@ -1885,6 +1986,12 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut
|
||||
assert 'id="issue-sheet-body"' in html
|
||||
assert 'id="issue-labels"' in html and 'id="issue-assignees"' in html
|
||||
assert 'id="issue-comments"' in html
|
||||
assert 'id="load-older-issue-comments"' in html
|
||||
assert 'id="issue-conversation-status"' in html and 'aria-live="assertive"' in html
|
||||
assert "issueController.conversation(item, detail.conversation)" in html
|
||||
assert "issueConversation.loadOlder()" in html
|
||||
assert "issueConversation.append(comment)" in html
|
||||
assert '.conversation-more { min-height:44px;' in html
|
||||
assert 'id="issue-comment"' in html and 'maxlength="10000"' in html
|
||||
assert 'id="send-issue-comment"' in html
|
||||
assert 'id="close-issue"' in html
|
||||
|
||||
@@ -94,6 +94,40 @@ async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
|
||||
assert "+new" in detail["files"][0]["diff_lines"]
|
||||
assert detail["files"][1]["diff_binary"] is True
|
||||
assert detail["files"][1]["diff_available"] is False
|
||||
assert detail["conversation"] == {
|
||||
"comments": [], "page": 1, "older_page": None, "total": 0
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_conversation_endpoint_reuses_issue_thread_with_pull_authorization(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
calls.append(("assigned", repository, number))
|
||||
return True
|
||||
|
||||
async def conversation(repository, number, page, limit):
|
||||
calls.append(("conversation", repository, number, page, limit))
|
||||
return {"comments": [{"id": 41}], "page": 3, "older_page": 2, "total": 47}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/comments?page=3&limit=20"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"comments": [{"id": 41}], "page": 3, "older_page": 2, "total": 47
|
||||
}
|
||||
assert calls == [
|
||||
("assigned", "stackchain/api", 7),
|
||||
("conversation", "stackchain/api", 7, 3, 20),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
Reference in new issue
Block a user