Watch an unassigned issue from mobile Search #1291
|
|
@ -5617,6 +5617,7 @@
|
||||||
const queueButton = qs('#queue-search-result');
|
const queueButton = qs('#queue-search-result');
|
||||||
const planButton = qs('#plan-search-result');
|
const planButton = qs('#plan-search-result');
|
||||||
const startButton = qs('#start-search-result');
|
const startButton = qs('#start-search-result');
|
||||||
|
const watchButton = qs('#watch-search-result');
|
||||||
const shareButton = qs('#share-search-result');
|
const shareButton = qs('#share-search-result');
|
||||||
|
|
||||||
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
|
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
|
||||||
|
|
@ -5636,6 +5637,8 @@
|
||||||
planButton.disabled = false;
|
planButton.disabled = false;
|
||||||
startButton.hidden = true;
|
startButton.hidden = true;
|
||||||
startButton.disabled = false;
|
startButton.disabled = false;
|
||||||
|
watchButton.hidden = true;
|
||||||
|
watchButton.disabled = false;
|
||||||
shareButton.disabled = true;
|
shareButton.disabled = true;
|
||||||
renderSearchPreviewReply(state, null, searchPreview, document);
|
renderSearchPreviewReply(state, null, searchPreview, document);
|
||||||
|
|
||||||
|
|
@ -5690,6 +5693,7 @@
|
||||||
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
|
startButton.textContent = detail.reviewable ? 'Review now' : detail.reopenable ? 'Reopen & resume' :
|
||||||
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
|
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
|
||||||
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
|
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
|
||||||
|
renderSearchPreviewWatch(detail, state, watchButton);
|
||||||
const shareStatus = {
|
const shareStatus = {
|
||||||
sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.',
|
sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.',
|
||||||
'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.',
|
'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.',
|
||||||
|
|
@ -5698,13 +5702,15 @@
|
||||||
else if (state.status === 'reopening') status.textContent = 'Reopening…';
|
else if (state.status === 'reopening') status.textContent = 'Reopening…';
|
||||||
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
|
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
|
||||||
else if (state.status === 'claimed') status.textContent = 'Assigned. Opening My Work…';
|
else if (state.status === 'claimed') status.textContent = 'Assigned. Opening My Work…';
|
||||||
|
else if (state.status.includes('watch')) status.textContent = searchPreviewWatchStatus(state);
|
||||||
else if (detail.claimable) status.textContent = 'Open and unassigned.';
|
else if (detail.claimable) status.textContent = 'Open and unassigned.';
|
||||||
else if (detail.assigned_to_me) status.textContent = 'Already in My Work.';
|
else if (detail.assigned_to_me) status.textContent = 'Already in My Work.';
|
||||||
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
|
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
|
||||||
else status.textContent = 'Ready.';
|
else status.textContent = 'Ready.';
|
||||||
}
|
}
|
||||||
|
const searchSubscription = searchPreviewSubscriptionOptions(fetchReviewJson);
|
||||||
const searchPreview = createSearchPreview({
|
const searchPreview = createSearchPreview({
|
||||||
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
|
fetchJson:searchSubscription.preview,
|
||||||
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page),{
|
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page),{
|
||||||
headers:{Accept:'application/json'},
|
headers:{Accept:'application/json'},
|
||||||
}),
|
}),
|
||||||
|
|
@ -5713,6 +5719,7 @@
|
||||||
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
|
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
|
||||||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||||||
),
|
),
|
||||||
|
watch:searchSubscription.watch,
|
||||||
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
||||||
queueReply:async (item,body,operationId) => {
|
queueReply:async (item,body,operationId) => {
|
||||||
searchReplyAttachmentTarget = item;
|
searchReplyAttachmentTarget = item;
|
||||||
|
|
@ -6078,6 +6085,7 @@
|
||||||
qs('#share-search-result').addEventListener('click', () => {
|
qs('#share-search-result').addEventListener('click', () => {
|
||||||
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
|
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
|
||||||
});
|
});
|
||||||
|
wireSearchPreviewWatch(qs('#watch-search-result'), searchPreview, () => searchPreviewDetail);
|
||||||
document.querySelectorAll('.share-work-route').forEach(button => {
|
document.querySelectorAll('.share-work-route').forEach(button => {
|
||||||
button.addEventListener('click', async () => {
|
button.addEventListener('click', async () => {
|
||||||
const status = qs('#work-route-share-status');
|
const status = qs('#work-route-share-status');
|
||||||
|
|
|
||||||
|
|
@ -957,7 +957,7 @@
|
||||||
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
<button id="queue-search-result" type="button" hidden>Assign & add to Today</button>
|
||||||
<button id="plan-search-result" type="button" hidden>Plan ahead</button>
|
<button id="plan-search-result" type="button" hidden>Plan ahead</button>
|
||||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||||
|
<button id="watch-search-result" type="button" hidden>Watch issue</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="share-search-result" type="button" disabled>Share result</button>
|
<button id="share-search-result" type="button" disabled>Share result</button>
|
||||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,33 @@
|
||||||
};
|
};
|
||||||
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||||
'/comments?kind=' + encodeURIComponent(item.kind);
|
'/comments?kind=' + encodeURIComponent(item.kind);
|
||||||
|
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||||
|
'/subscription?kind=' + encodeURIComponent(item.kind);
|
||||||
|
root.searchPreviewSubscriptionOptions = fetchJson => ({
|
||||||
|
load:async detail => {
|
||||||
|
if (!(detail.kind === 'issue' && detail.state === 'open' && detail.claimable)) return detail;
|
||||||
|
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
|
||||||
|
return {...detail, watching:result.watching === true};
|
||||||
|
},
|
||||||
|
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
|
||||||
|
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
root.searchPreviewWatchStatus = state => ({
|
||||||
|
watching:'Starting watch…', unwatching:'Stopping watch…',
|
||||||
|
watched:'Watching. Future activity will appear in Updates.',
|
||||||
|
unwatched:'Stopped watching. Assignment and planning are unchanged.',
|
||||||
|
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
|
||||||
|
})[state.status] || '';
|
||||||
|
root.renderSearchPreviewWatch = (detail, state, button) => {
|
||||||
|
button.hidden = !(detail.kind === 'issue' && detail.state === 'open' && detail.claimable);
|
||||||
|
button.textContent = detail.watching ? 'Stop watching' : 'Watch issue';
|
||||||
|
button.disabled = state.status === 'watching' || state.status === 'unwatching';
|
||||||
|
};
|
||||||
|
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
|
||||||
|
const detail = getDetail();
|
||||||
|
if (detail) preview.setWatching(detail.watching !== true).catch(() => {});
|
||||||
|
});
|
||||||
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
|
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
|
||||||
storage,
|
storage,
|
||||||
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||||
|
|
@ -92,7 +119,7 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||||
if (Array.isArray(session)) {
|
if (Array.isArray(session)) {
|
||||||
getSession = session[0];
|
getSession = session[0];
|
||||||
loadMore = () => session[1].loadMore();
|
loadMore = () => session[1].loadMore();
|
||||||
|
|
@ -105,6 +132,7 @@
|
||||||
let shareRequest = null;
|
let shareRequest = null;
|
||||||
let moveRequest = null;
|
let moveRequest = null;
|
||||||
let replyRequest = null;
|
let replyRequest = null;
|
||||||
|
let watchRequest = null;
|
||||||
let conversation = null;
|
let conversation = null;
|
||||||
|
|
||||||
function sameItem(left, right) {
|
function sameItem(left, right) {
|
||||||
|
|
@ -348,6 +376,23 @@
|
||||||
reopen(detail) {
|
reopen(detail) {
|
||||||
return run('reopen', 'reopening', 'reopened', detail);
|
return run('reopen', 'reopening', 'reopened', detail);
|
||||||
},
|
},
|
||||||
|
setWatching(watching) {
|
||||||
|
if (watchRequest) return watchRequest;
|
||||||
|
if (!current || typeof watch !== 'function') {
|
||||||
|
return Promise.reject(new Error('Watching is unavailable.'));
|
||||||
|
}
|
||||||
|
const detail = current;
|
||||||
|
publish({ status:watching ? 'watching' : 'unwatching', item:current, detail });
|
||||||
|
watchRequest = watch(detail, watching).then(result => {
|
||||||
|
current = { ...current, watching:result?.watching === true };
|
||||||
|
publish({ status:watching ? 'watched' : 'unwatched', item:current, detail:current, result });
|
||||||
|
return result;
|
||||||
|
}).catch(error => {
|
||||||
|
publish({ status:'watch-error', item:current, detail, error });
|
||||||
|
throw error;
|
||||||
|
}).finally(() => { watchRequest = null; });
|
||||||
|
return watchRequest;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if (navigationRoot) {
|
if (navigationRoot) {
|
||||||
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
|
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
|
||||||
|
|
|
||||||
|
|
@ -1007,6 +1007,39 @@ async def _notification_subscription(repository: str, number: str) -> dict:
|
||||||
return value if isinstance(value, dict) else {}
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
async def issue_subscription(repository: str, number: int) -> dict:
|
||||||
|
"""Return the current operator's authoritative issue subscription state."""
|
||||||
|
value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("Gitea subscription response was not an object")
|
||||||
|
return {
|
||||||
|
"watching": value.get("subscribed") is True and value.get("ignored") is not True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def set_issue_subscription(repository: str, number: int, watching: bool) -> dict:
|
||||||
|
"""Set and then confirm the current operator's issue subscription state."""
|
||||||
|
user = await fetch("user")
|
||||||
|
login = user.get("login") if isinstance(user, dict) else None
|
||||||
|
if not isinstance(login, str) or not login:
|
||||||
|
raise ValueError("Gitea did not identify the current operator")
|
||||||
|
path = (
|
||||||
|
f"/api/v1/repos/{repository}/issues/{number}/subscriptions/"
|
||||||
|
f"{quote(login, safe='')}"
|
||||||
|
)
|
||||||
|
client = _get_client()
|
||||||
|
response = (
|
||||||
|
await client.put(path, headers=_auth())
|
||||||
|
if watching
|
||||||
|
else await client.delete(path, headers=_auth())
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
confirmed = await issue_subscription(repository, number)
|
||||||
|
if confirmed["watching"] is not watching:
|
||||||
|
raise ValueError("Gitea did not confirm the requested subscription state")
|
||||||
|
return confirmed
|
||||||
|
|
||||||
|
|
||||||
async def notification_detail(thread_id: int) -> dict:
|
async def notification_detail(thread_id: int) -> 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):
|
||||||
|
|
|
||||||
69
src/main.py
69
src/main.py
|
|
@ -3660,6 +3660,75 @@ async def global_search_preview(
|
||||||
return JSONResponse(preview)
|
return JSONResponse(preview)
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_preview_subscription_target(
|
||||||
|
owner: str, repo: str, number: int, kind: str
|
||||||
|
) -> str:
|
||||||
|
repository = f"{owner}/{repo}"
|
||||||
|
preview = await gitea_proxy.work_preview(repository, kind, number)
|
||||||
|
if (
|
||||||
|
kind != "issue"
|
||||||
|
or preview.get("repository") != repository
|
||||||
|
or preview.get("kind") != "issue"
|
||||||
|
or preview.get("number") != number
|
||||||
|
or preview.get("state") != "open"
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Watchable search result not found")
|
||||||
|
return repository
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription")
|
||||||
|
async def global_search_preview_subscription(
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int = PathParam(gt=0),
|
||||||
|
kind: Literal["issue", "pull"] = Query(),
|
||||||
|
) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
repository = await _search_preview_subscription_target(owner, repo, number, kind)
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
gitea_proxy.issue_subscription(repository, number),
|
||||||
|
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Watch status is temporarily unavailable. Please retry."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route(
|
||||||
|
"/api/v1/repos/{owner}/{repo}/issues/{number}/preview/subscription",
|
||||||
|
methods=["PUT", "DELETE"],
|
||||||
|
)
|
||||||
|
async def mutate_global_search_preview_subscription(
|
||||||
|
request: Request,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int = PathParam(gt=0),
|
||||||
|
kind: Literal["issue", "pull"] = Query(),
|
||||||
|
) -> JSONResponse:
|
||||||
|
watching = request.method == "PUT"
|
||||||
|
try:
|
||||||
|
repository = await _search_preview_subscription_target(owner, repo, number, kind)
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
gitea_proxy.set_issue_subscription(repository, number, watching),
|
||||||
|
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Watch status was not changed. Please retry."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation")
|
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation")
|
||||||
async def global_search_preview_conversation(
|
async def global_search_preview_conversation(
|
||||||
owner: str,
|
owner: str,
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,14 @@ def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_d
|
||||||
page.locator("#search-preview-conversation").evaluate("node => node.style.minHeight = '500px'")
|
page.locator("#search-preview-conversation").evaluate("node => node.style.minHeight = '500px'")
|
||||||
page.locator("#search-preview-reply-workspace").evaluate("node => node.style.minHeight = '500px'")
|
page.locator("#search-preview-reply-workspace").evaluate("node => node.style.minHeight = '500px'")
|
||||||
page.locator("#search-preview-actions").evaluate("node => node.style.minHeight = '300px'")
|
page.locator("#search-preview-actions").evaluate("node => node.style.minHeight = '300px'")
|
||||||
|
page.locator("#watch-search-result").evaluate("node => { node.hidden = false; node.textContent = 'Watch issue'; }")
|
||||||
page.add_script_tag(path=FRONTEND / "mobile-search-preview-nav.js")
|
page.add_script_tag(path=FRONTEND / "mobile-search-preview-nav.js")
|
||||||
|
|
||||||
|
watch = page.locator("#watch-search-result")
|
||||||
|
expect(watch).to_be_visible()
|
||||||
|
expect(watch).to_have_text("Watch issue")
|
||||||
|
watch_bounds = watch.bounding_box()
|
||||||
|
assert watch_bounds and watch_bounds["height"] >= 44
|
||||||
navigation = page.locator(".mobile-search-preview-nav")
|
navigation = page.locator(".mobile-search-preview-nav")
|
||||||
expect(navigation).to_be_visible()
|
expect(navigation).to_be_visible()
|
||||||
buttons = navigation.locator("button")
|
buttons = navigation.locator("button")
|
||||||
|
|
|
||||||
|
|
@ -806,6 +806,49 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
|
||||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_preview_watch_is_single_flight_and_keeps_visible_context_on_failure():
|
||||||
|
script = f"""
|
||||||
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||||
|
(async () => {{
|
||||||
|
let calls = 0; let rejectWatch; const states = [];
|
||||||
|
const detail = {{repository:'stackchain/api',number:42,kind:'issue',claimable:true,watching:false}};
|
||||||
|
const preview = createSearchPreview({{
|
||||||
|
fetchJson:async()=>detail, mutate:async()=>{{}},
|
||||||
|
watch:()=>{{calls+=1;return new Promise((_resolve,reject)=>rejectWatch=reject);}},
|
||||||
|
onState:state=>states.push(state),
|
||||||
|
}});
|
||||||
|
await preview.open(detail);
|
||||||
|
const first=preview.setWatching(true); const second=preview.setWatching(true);
|
||||||
|
if (calls !== 1 || first !== second) throw new Error('watch mutation was not single-flight');
|
||||||
|
rejectWatch(new Error('offline')); await Promise.allSettled([first,second]);
|
||||||
|
const final=states.at(-1);
|
||||||
|
process.stdout.write(JSON.stringify({{calls,status:final.status,number:final.detail.number,
|
||||||
|
watching:final.detail.watching,error:final.error.message}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"calls": 1,
|
||||||
|
"status": "watch-error",
|
||||||
|
"number": 42,
|
||||||
|
"watching": False,
|
||||||
|
"error": "offline",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_search_preview_exposes_touch_safe_watch_action():
|
||||||
|
html = dashboard_bundle_text()
|
||||||
|
css = (FRONTEND / "dashboard.css").read_text()
|
||||||
|
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||||
|
|
||||||
|
assert '<button id="watch-search-result" type="button" hidden>Watch issue</button>' in html
|
||||||
|
assert "wireSearchPreviewWatch" in dashboard
|
||||||
|
assert "preview.setWatching" in SEARCH_PREVIEW.read_text()
|
||||||
|
assert ".search-preview-actions button" in css and "min-height:44px" in css
|
||||||
|
|
||||||
|
|
||||||
def test_search_preview_shares_canonical_url_without_closing_the_preview():
|
def test_search_preview_shares_canonical_url_without_closing_the_preview():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,96 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
|
||||||
assert response.json()["claimable"] is True
|
assert response.json()["claimable"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_search_preview_subscription_reads_server_truth_without_caching(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def preview(repository, kind, number):
|
||||||
|
calls.append(("preview", repository, kind, number))
|
||||||
|
return {"repository": repository, "kind": kind, "number": number,
|
||||||
|
"state": "open", "claimable": True}
|
||||||
|
|
||||||
|
async def subscription(repository, number):
|
||||||
|
calls.append(("subscription", repository, number))
|
||||||
|
return {"watching": True}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "issue_subscription", subscription, 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/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert response.json() == {"watching": True}
|
||||||
|
assert calls == [
|
||||||
|
("preview", "stackchain/api", "issue", 42),
|
||||||
|
("subscription", "stackchain/api", 42),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)])
|
||||||
|
async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth(
|
||||||
|
monkeypatch, method, watching
|
||||||
|
):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def preview(repository, kind, number):
|
||||||
|
calls.append(("preview", repository, kind, number))
|
||||||
|
return {"repository": repository, "kind": kind, "number": number,
|
||||||
|
"state": "open", "claimable": True}
|
||||||
|
|
||||||
|
async def set_subscription(repository, number, desired):
|
||||||
|
calls.append(("set", repository, number, desired))
|
||||||
|
return {"watching": desired}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.request(
|
||||||
|
method,
|
||||||
|
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert response.json() == {"watching": watching}
|
||||||
|
assert calls == [
|
||||||
|
("preview", "stackchain/api", "issue", 42),
|
||||||
|
("set", "stackchain/api", 42, watching),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_issue_subscription_mutation_uses_current_login_and_requires_confirmation():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
requests.append((request.method, request.url.path))
|
||||||
|
if request.url.path.endswith("/user"):
|
||||||
|
return httpx.Response(200, json={"login": "timmy"})
|
||||||
|
if request.url.path.endswith("/subscriptions/check"):
|
||||||
|
return httpx.Response(200, json={"subscribed": True, "ignored": False})
|
||||||
|
return httpx.Response(201, json={})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.set_issue_subscription("stackchain/api", 42, True)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert result == {"watching": True}
|
||||||
|
assert requests == [
|
||||||
|
("GET", "/api/v1/user"),
|
||||||
|
("PUT", "/api/v1/repos/stackchain/api/issues/42/subscriptions/timmy"),
|
||||||
|
("GET", "/api/v1/repos/stackchain/api/issues/42/subscriptions/check"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@pytest.mark.parametrize("kind", ["issue", "pull"])
|
@pytest.mark.parametrize("kind", ["issue", "pull"])
|
||||||
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(
|
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user