Compare commits
No commits in common. "678005ece95fa4a620812ef357999429ed5c24d5" and "d75a55f3f79fd5391dc584419d359a2433bf9ea7" have entirely different histories.
678005ece9
...
d75a55f3f7
|
|
@ -131,7 +131,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-work-list" id="my-work-list"></div>
|
<div class="my-work-list" id="my-work-list"></div>
|
||||||
|
|
@ -301,12 +300,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
let selectedWorkFilter = 'all';
|
let selectedWorkFilter = 'all';
|
||||||
try {
|
try {
|
||||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||||
if (['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
if (['all', 'issue', 'pull', 'review'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Could not restore My Work filter', e);
|
console.warn('Could not restore My Work filter', e);
|
||||||
}
|
}
|
||||||
let lastMyWork = [];
|
let lastMyWork = [];
|
||||||
let lastNotifications = [];
|
|
||||||
let hasContextSnapshot = false;
|
let hasContextSnapshot = false;
|
||||||
let selectedReview = null;
|
let selectedReview = null;
|
||||||
let reviewTrigger = null;
|
let reviewTrigger = null;
|
||||||
|
|
@ -383,7 +381,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
});
|
});
|
||||||
qs('#my-work').removeAttribute('data-stale');
|
qs('#my-work').removeAttribute('data-stale');
|
||||||
qs('#my-work-status').textContent = lastMyWork.length ?
|
qs('#my-work-status').textContent = lastMyWork.length ?
|
||||||
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
|
summarizeMyWork(lastMyWork) : 'No assigned work or review requests.';
|
||||||
renderMyWork();
|
renderMyWork();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -391,7 +389,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
|
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
|
||||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||||
const contents =
|
const contents =
|
||||||
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
|
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : 'Issue') + '</span>' +
|
||||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||||
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
|
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
|
||||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||||||
|
|
@ -400,7 +398,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
return '<button class="my-work-card review-trigger" data-review-index="' + index + '">' + contents + '</button>';
|
return '<button class="my-work-card review-trigger" data-review-index="' + index + '">' + contents + '</button>';
|
||||||
}
|
}
|
||||||
return '<a class="my-work-card" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>';
|
return '<a class="my-work-card" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>';
|
||||||
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.</div>';
|
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')) + '.</div>';
|
||||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||||
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button));
|
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button));
|
||||||
});
|
});
|
||||||
|
|
@ -535,13 +533,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
'Update failed · showing last known work' : 'Work inbox unavailable.';
|
'Update failed · showing last known work' : 'Work inbox unavailable.';
|
||||||
}
|
}
|
||||||
|
|
||||||
function markNotificationsStale() {
|
|
||||||
qs('#my-work').setAttribute('data-stale', 'true');
|
|
||||||
qs('#my-work-status').textContent = lastNotifications.length ?
|
|
||||||
'Unread updates unavailable · showing last known updates' :
|
|
||||||
'Unread updates unavailable · assigned work is fresh';
|
|
||||||
}
|
|
||||||
|
|
||||||
function paintDeltas(deltas) {
|
function paintDeltas(deltas) {
|
||||||
const el = qs('#ai');
|
const el = qs('#ai');
|
||||||
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
|
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
|
||||||
|
|
@ -562,13 +553,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLiveSnapshot(snapshot) {
|
function renderLiveSnapshot(snapshot) {
|
||||||
const notificationsFresh = Array.isArray(snapshot.notifications);
|
if (snapshot.context) renderContextSnapshot(snapshot.context);
|
||||||
if (notificationsFresh) lastNotifications = snapshot.notifications;
|
else handleContextError(new Error('Context section unavailable'));
|
||||||
if (snapshot.context) {
|
|
||||||
snapshot.context.notifications = lastNotifications;
|
|
||||||
renderContextSnapshot(snapshot.context);
|
|
||||||
if (!notificationsFresh) markNotificationsStale();
|
|
||||||
} else handleContextError(new Error('Context section unavailable'));
|
|
||||||
if (snapshot.events) {
|
if (snapshot.events) {
|
||||||
paintEventStream(snapshot.events);
|
paintEventStream(snapshot.events);
|
||||||
setEventStreamStatus('Updated ' + fmt(new Date()));
|
setEventStreamStatus('Updated ' + fmt(new Date()));
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ function buildMyWork(data) {
|
||||||
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
|
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
|
||||||
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||||
|
|
||||||
const work = issues.concat(pulls).map((item) => {
|
return issues.concat(pulls).map((item) => {
|
||||||
const labels = item.labels || [];
|
const labels = item.labels || [];
|
||||||
const priorityLabel = labels.find((label) =>
|
const priorityLabel = labels.find((label) =>
|
||||||
priorityLabels.includes(String(label).toLowerCase())
|
priorityLabels.includes(String(label).toLowerCase())
|
||||||
|
|
@ -16,41 +16,11 @@ function buildMyWork(data) {
|
||||||
key: (item.repository || 'unknown') + '#' + item.number,
|
key: (item.repository || 'unknown') + '#' + item.number,
|
||||||
is_review: isReview,
|
is_review: isReview,
|
||||||
is_assigned: assigned,
|
is_assigned: assigned,
|
||||||
has_update: false,
|
|
||||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')),
|
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')),
|
||||||
_priority: priorityLabel ? 0 : (isReview ? 2 : (assigned ? 3 : 4)),
|
_priority: priorityLabel ? 0 : (isReview ? 1 : (assigned ? 2 : 3)),
|
||||||
};
|
};
|
||||||
});
|
}).sort((left, right) =>
|
||||||
|
|
||||||
const byKey = new Map(work.map((item) => [item.kind + ':' + item.key, item]));
|
|
||||||
(data.notifications || []).filter((item) => item && item.unread).forEach((update) => {
|
|
||||||
const key = (update.repository || 'unknown') + '#' + update.number;
|
|
||||||
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
|
|
||||||
const existing = byKey.get(subjectKind + ':' + key);
|
|
||||||
if (existing) {
|
|
||||||
existing.has_update = true;
|
|
||||||
existing.url = update.url || existing.url;
|
|
||||||
existing.updated_at = update.updated_at || existing.updated_at;
|
|
||||||
existing._priority = Math.min(existing._priority, 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!update.url) return;
|
|
||||||
const item = {
|
|
||||||
...update,
|
|
||||||
key,
|
|
||||||
kind: 'update',
|
|
||||||
is_review: false,
|
|
||||||
is_assigned: false,
|
|
||||||
has_update: true,
|
|
||||||
reason: 'Unread update',
|
|
||||||
_priority: 1,
|
|
||||||
};
|
|
||||||
work.push(item);
|
|
||||||
byKey.set(subjectKind + ':' + key, item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return work.sort((left, right) =>
|
|
||||||
left._priority - right._priority ||
|
left._priority - right._priority ||
|
||||||
String(right.updated_at || '').localeCompare(String(left.updated_at || '')) ||
|
String(right.updated_at || '').localeCompare(String(left.updated_at || '')) ||
|
||||||
left.key.localeCompare(right.key)
|
left.key.localeCompare(right.key)
|
||||||
|
|
@ -60,18 +30,15 @@ function buildMyWork(data) {
|
||||||
function filterMyWork(items, selectedFilter) {
|
function filterMyWork(items, selectedFilter) {
|
||||||
if (selectedFilter === 'all') return items;
|
if (selectedFilter === 'all') return items;
|
||||||
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
|
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
|
||||||
if (selectedFilter === 'update') return items.filter((item) => item.has_update);
|
|
||||||
return items.filter((item) => item.kind === selectedFilter);
|
return items.filter((item) => item.kind === selectedFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeMyWork(items) {
|
function summarizeMyWork(items) {
|
||||||
const updates = items.filter((item) => item.has_update).length;
|
|
||||||
const reviews = items.filter((item) => item.is_review).length;
|
const reviews = items.filter((item) => item.is_review).length;
|
||||||
const assigned = items.filter((item) => item.is_assigned).length;
|
const assigned = items.filter((item) => item.is_assigned).length;
|
||||||
const updateLabel = updates + ' unread update' + (updates === 1 ? '' : 's');
|
|
||||||
const reviewLabel = reviews + ' review' + (reviews === 1 ? '' : 's');
|
const reviewLabel = reviews + ' review' + (reviews === 1 ? '' : 's');
|
||||||
const assignedLabel = assigned + ' assigned';
|
const assignedLabel = assigned + ' assigned';
|
||||||
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
|
return reviewLabel + ' · ' + assignedLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function countMyWork(items) {
|
function countMyWork(items) {
|
||||||
|
|
@ -80,7 +47,6 @@ function countMyWork(items) {
|
||||||
issue: items.filter((item) => item.kind === 'issue').length,
|
issue: items.filter((item) => item.kind === 'issue').length,
|
||||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||||
review: items.filter((item) => item.is_review).length,
|
review: items.filter((item) => item.is_review).length,
|
||||||
update: items.filter((item) => item.has_update).length,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import asyncio
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlsplit
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
@ -118,69 +117,6 @@ async def issues() -> list[dict]:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _safe_web_url(value: Any) -> str:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return ""
|
|
||||||
parsed = urlsplit(value)
|
|
||||||
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
|
|
||||||
|
|
||||||
|
|
||||||
async def notifications() -> list[dict]:
|
|
||||||
threads = await fetch("notifications?status-types=unread&limit=50")
|
|
||||||
if not isinstance(threads, list):
|
|
||||||
raise ValueError("Gitea notification response was not a list")
|
|
||||||
normalized = []
|
|
||||||
for thread in threads:
|
|
||||||
if not isinstance(thread, dict):
|
|
||||||
continue
|
|
||||||
repository = thread.get("repository")
|
|
||||||
subject = thread.get("subject")
|
|
||||||
repository = repository if isinstance(repository, dict) else {}
|
|
||||||
subject = subject if isinstance(subject, dict) else {}
|
|
||||||
subject_url = _safe_web_url(subject.get("html_url"))
|
|
||||||
latest_url = _safe_web_url(subject.get("latest_comment_html_url"))
|
|
||||||
number_text = (
|
|
||||||
urlsplit(subject_url).path.rstrip("/").rsplit("/", 1)[-1]
|
|
||||||
if subject_url
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
normalized.append(
|
|
||||||
{
|
|
||||||
"id": thread.get("id"),
|
|
||||||
"unread": thread.get("unread") is True,
|
|
||||||
"updated_at": (
|
|
||||||
thread.get("updated_at")
|
|
||||||
if isinstance(thread.get("updated_at"), str)
|
|
||||||
else ""
|
|
||||||
),
|
|
||||||
"repository": (
|
|
||||||
repository.get("full_name")
|
|
||||||
if isinstance(repository.get("full_name"), str)
|
|
||||||
else ""
|
|
||||||
),
|
|
||||||
"number": int(number_text) if number_text.isdigit() else None,
|
|
||||||
"title": (
|
|
||||||
subject.get("title")
|
|
||||||
if isinstance(subject.get("title"), str) and subject.get("title")
|
|
||||||
else "Untitled update"
|
|
||||||
),
|
|
||||||
"subject_type": (
|
|
||||||
subject.get("type")
|
|
||||||
if isinstance(subject.get("type"), str) and subject.get("type")
|
|
||||||
else "Update"
|
|
||||||
),
|
|
||||||
"state": (
|
|
||||||
subject.get("state")
|
|
||||||
if isinstance(subject.get("state"), str)
|
|
||||||
else ""
|
|
||||||
),
|
|
||||||
"url": latest_url or subject_url,
|
|
||||||
"subject_url": subject_url,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
async def pull_requests() -> list[dict]:
|
async def pull_requests() -> list[dict]:
|
||||||
assigned, review_requested = await asyncio.gather(
|
assigned, review_requested = await asyncio.gather(
|
||||||
fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"),
|
fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"),
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ from src.gitea_proxy import (
|
||||||
current_user,
|
current_user,
|
||||||
is_requested_review,
|
is_requested_review,
|
||||||
issues,
|
issues,
|
||||||
notifications,
|
|
||||||
pull_requests,
|
pull_requests,
|
||||||
pull_review_detail,
|
pull_review_detail,
|
||||||
repos,
|
repos,
|
||||||
|
|
@ -225,23 +224,19 @@ async def _build_live_snapshot() -> dict:
|
||||||
user_data = await current_user()
|
user_data = await current_user()
|
||||||
if not isinstance(user_data, dict) or not user_data.get("login"):
|
if not isinstance(user_data, dict) or not user_data.get("login"):
|
||||||
raise ContextPayloadError("Gitea current-user response was invalid")
|
raise ContextPayloadError("Gitea current-user response was invalid")
|
||||||
context_result, events_result, notifications_result = await asyncio.gather(
|
context_result, events_result = await asyncio.gather(
|
||||||
_load_context_for_user(user_data),
|
_load_context_for_user(user_data),
|
||||||
activity_events(user_data),
|
activity_events(user_data),
|
||||||
notifications(),
|
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
context_ok = not isinstance(context_result, BaseException)
|
context_ok = not isinstance(context_result, BaseException)
|
||||||
events_ok = not isinstance(events_result, BaseException)
|
events_ok = not isinstance(events_result, BaseException)
|
||||||
notifications_ok = not isinstance(notifications_result, BaseException)
|
|
||||||
return {
|
return {
|
||||||
"context": context_result if context_ok else None,
|
"context": context_result if context_ok else None,
|
||||||
"events": events_result if events_ok else None,
|
"events": events_result if events_ok else None,
|
||||||
"notifications": notifications_result if notifications_ok else None,
|
|
||||||
"sections": {
|
"sections": {
|
||||||
"context": "fresh" if context_ok else "temporarily unavailable",
|
"context": "fresh" if context_ok else "temporarily unavailable",
|
||||||
"events": "fresh" if events_ok else "temporarily unavailable",
|
"events": "fresh" if events_ok else "temporarily unavailable",
|
||||||
"notifications": "fresh" if notifications_ok else "temporarily unavailable",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src import gitea_proxy
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handoff(monkeypatch):
|
|
||||||
requested_paths = []
|
|
||||||
|
|
||||||
async def fake_fetch(path):
|
|
||||||
requested_paths.append(path)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": 42,
|
|
||||||
"unread": True,
|
|
||||||
"updated_at": "2026-08-06T12:30:00Z",
|
|
||||||
"repository": {"full_name": "stackchain/api"},
|
|
||||||
"subject": {
|
|
||||||
"title": "Retry failed deploy",
|
|
||||||
"type": "Issue",
|
|
||||||
"state": "open",
|
|
||||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
|
||||||
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{"id": 43, "repository": None, "subject": None},
|
|
||||||
{
|
|
||||||
"id": 44,
|
|
||||||
"repository": {"full_name": "stackchain/web"},
|
|
||||||
"subject": {"title": "Unsafe", "html_url": "javascript:alert(1)"},
|
|
||||||
},
|
|
||||||
"malformed",
|
|
||||||
]
|
|
||||||
|
|
||||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
|
||||||
|
|
||||||
result = await gitea_proxy.notifications()
|
|
||||||
|
|
||||||
assert requested_paths == ["notifications?status-types=unread&limit=50"]
|
|
||||||
assert result == [
|
|
||||||
{
|
|
||||||
"id": 42,
|
|
||||||
"unread": True,
|
|
||||||
"updated_at": "2026-08-06T12:30:00Z",
|
|
||||||
"repository": "stackchain/api",
|
|
||||||
"number": 7,
|
|
||||||
"title": "Retry failed deploy",
|
|
||||||
"subject_type": "Issue",
|
|
||||||
"state": "open",
|
|
||||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
|
||||||
"subject_url": "https://forge.example/stackchain/api/issues/7",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 43,
|
|
||||||
"unread": False,
|
|
||||||
"updated_at": "",
|
|
||||||
"repository": "",
|
|
||||||
"number": None,
|
|
||||||
"title": "Untitled update",
|
|
||||||
"subject_type": "Update",
|
|
||||||
"state": "",
|
|
||||||
"url": "",
|
|
||||||
"subject_url": "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 44,
|
|
||||||
"unread": False,
|
|
||||||
"updated_at": "",
|
|
||||||
"repository": "stackchain/web",
|
|
||||||
"number": None,
|
|
||||||
"title": "Unsafe",
|
|
||||||
"subject_type": "Update",
|
|
||||||
"state": "",
|
|
||||||
"url": "",
|
|
||||||
"subject_url": "",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_notification_collection_rejects_non_list_payload(monkeypatch):
|
|
||||||
async def fake_fetch(_path):
|
|
||||||
return {"message": "unexpected"}
|
|
||||||
|
|
||||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="notification response was not a list"):
|
|
||||||
await gitea_proxy.notifications()
|
|
||||||
|
|
@ -32,15 +32,11 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
|
||||||
assert authenticated_user["login"] == "timmy"
|
assert authenticated_user["login"] == "timmy"
|
||||||
return [{"type": "push"}]
|
return [{"type": "push"}]
|
||||||
|
|
||||||
async def updates():
|
|
||||||
return [{"id": 42, "title": "Mentioned you"}]
|
|
||||||
|
|
||||||
monkeypatch.setattr(main, "current_user", user)
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
monkeypatch.setattr(main, "repos", empty)
|
monkeypatch.setattr(main, "repos", empty)
|
||||||
monkeypatch.setattr(main, "issues", empty)
|
monkeypatch.setattr(main, "issues", empty)
|
||||||
monkeypatch.setattr(main, "pull_requests", empty)
|
monkeypatch.setattr(main, "pull_requests", empty)
|
||||||
monkeypatch.setattr(main, "activity_events", events)
|
monkeypatch.setattr(main, "activity_events", events)
|
||||||
monkeypatch.setattr(main, "notifications", updates)
|
|
||||||
|
|
||||||
response = await main.live_snapshot()
|
response = await main.live_snapshot()
|
||||||
result = payload(response)
|
result = payload(response)
|
||||||
|
|
@ -48,10 +44,7 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
|
||||||
assert calls["user"] == 1
|
assert calls["user"] == 1
|
||||||
assert result["context"]["user"]["login"] == "timmy"
|
assert result["context"]["user"]["login"] == "timmy"
|
||||||
assert result["events"] == [{"type": "push"}]
|
assert result["events"] == [{"type": "push"}]
|
||||||
assert result["notifications"] == [{"id": 42, "title": "Mentioned you"}]
|
assert result["sections"] == {"context": "fresh", "events": "fresh"}
|
||||||
assert result["sections"] == {
|
|
||||||
"context": "fresh", "events": "fresh", "notifications": "fresh"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -70,7 +63,6 @@ async def test_live_snapshot_keeps_fresh_context_when_activity_fails(monkeypatch
|
||||||
monkeypatch.setattr(main, "issues", empty)
|
monkeypatch.setattr(main, "issues", empty)
|
||||||
monkeypatch.setattr(main, "pull_requests", empty)
|
monkeypatch.setattr(main, "pull_requests", empty)
|
||||||
monkeypatch.setattr(main, "activity_events", failing_events)
|
monkeypatch.setattr(main, "activity_events", failing_events)
|
||||||
monkeypatch.setattr(main, "notifications", empty)
|
|
||||||
|
|
||||||
result = payload(await main.live_snapshot())
|
result = payload(await main.live_snapshot())
|
||||||
|
|
||||||
|
|
@ -79,41 +71,10 @@ async def test_live_snapshot_keeps_fresh_context_when_activity_fails(monkeypatch
|
||||||
assert result["sections"] == {
|
assert result["sections"] == {
|
||||||
"context": "fresh",
|
"context": "fresh",
|
||||||
"events": "temporarily unavailable",
|
"events": "temporarily unavailable",
|
||||||
"notifications": "fresh",
|
|
||||||
}
|
}
|
||||||
assert "secret" not in json.dumps(result)
|
assert "secret" not in json.dumps(result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_live_snapshot_keeps_work_and_activity_when_notifications_fail(monkeypatch):
|
|
||||||
async def user():
|
|
||||||
return {"id": 1, "login": "timmy"}
|
|
||||||
|
|
||||||
async def empty():
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def events(_authenticated_user):
|
|
||||||
return [{"type": "push"}]
|
|
||||||
|
|
||||||
async def failing_updates():
|
|
||||||
raise ConnectionError("private notification failure")
|
|
||||||
|
|
||||||
monkeypatch.setattr(main, "current_user", user)
|
|
||||||
monkeypatch.setattr(main, "repos", empty)
|
|
||||||
monkeypatch.setattr(main, "issues", empty)
|
|
||||||
monkeypatch.setattr(main, "pull_requests", empty)
|
|
||||||
monkeypatch.setattr(main, "activity_events", events)
|
|
||||||
monkeypatch.setattr(main, "notifications", failing_updates)
|
|
||||||
|
|
||||||
result = payload(await main.live_snapshot())
|
|
||||||
|
|
||||||
assert result["context"]["user"]["login"] == "timmy"
|
|
||||||
assert result["events"] == [{"type": "push"}]
|
|
||||||
assert result["notifications"] is None
|
|
||||||
assert result["sections"]["notifications"] == "temporarily unavailable"
|
|
||||||
assert "private" not in json.dumps(result)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch):
|
async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch):
|
||||||
async def user():
|
async def user():
|
||||||
|
|
@ -133,7 +94,6 @@ async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch):
|
||||||
monkeypatch.setattr(main, "issues", empty)
|
monkeypatch.setattr(main, "issues", empty)
|
||||||
monkeypatch.setattr(main, "pull_requests", empty)
|
monkeypatch.setattr(main, "pull_requests", empty)
|
||||||
monkeypatch.setattr(main, "activity_events", events)
|
monkeypatch.setattr(main, "activity_events", events)
|
||||||
monkeypatch.setattr(main, "notifications", empty)
|
|
||||||
|
|
||||||
result = payload(await main.live_snapshot())
|
result = payload(await main.live_snapshot())
|
||||||
|
|
||||||
|
|
@ -142,7 +102,6 @@ async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch):
|
||||||
assert result["sections"] == {
|
assert result["sections"] == {
|
||||||
"context": "temporarily unavailable",
|
"context": "temporarily unavailable",
|
||||||
"events": "fresh",
|
"events": "fresh",
|
||||||
"notifications": "fresh",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -171,7 +130,6 @@ async def test_live_snapshot_coalesces_only_simultaneous_requests(monkeypatch):
|
||||||
monkeypatch.setattr(main, "issues", empty)
|
monkeypatch.setattr(main, "issues", empty)
|
||||||
monkeypatch.setattr(main, "pull_requests", empty)
|
monkeypatch.setattr(main, "pull_requests", empty)
|
||||||
monkeypatch.setattr(main, "activity_events", events)
|
monkeypatch.setattr(main, "activity_events", events)
|
||||||
monkeypatch.setattr(main, "notifications", empty)
|
|
||||||
|
|
||||||
first = asyncio.create_task(main.live_snapshot())
|
first = asyncio.create_task(main.live_snapshot())
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
|
|
|
||||||
|
|
@ -113,80 +113,7 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
|
||||||
["node", "-e", script], check=True, capture_output=True, text=True
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
)
|
)
|
||||||
|
|
||||||
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1, "update": 0}
|
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1}
|
||||||
|
|
||||||
|
|
||||||
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
|
|
||||||
payload = {
|
|
||||||
"user": {"login": "timmy"},
|
|
||||||
"issues": [{
|
|
||||||
"id": 1, "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
|
|
||||||
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z",
|
|
||||||
"url": "https://forge.example/stackchain/api/issues/7",
|
|
||||||
}],
|
|
||||||
"pull_requests": [],
|
|
||||||
"notifications": [
|
|
||||||
{
|
|
||||||
"id": 42, "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
|
|
||||||
"subject_type": "Issue", "unread": True, "updated_at": "2026-08-06T12:00:00Z",
|
|
||||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 43, "number": 8, "title": "Mention only", "repository": "stackchain/web",
|
|
||||||
"subject_type": "Issue", "unread": True, "updated_at": "2026-08-06T13:00:00Z",
|
|
||||||
"url": "https://forge.example/stackchain/web/issues/8#issuecomment-2",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
script = f"""
|
|
||||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
||||||
const queue = buildMyWork({json.dumps(payload)});
|
|
||||||
process.stdout.write(JSON.stringify({{
|
|
||||||
queue,
|
|
||||||
updates: buildMyWork.filterMyWork(queue, 'update'),
|
|
||||||
counts: buildMyWork.countMyWork(queue),
|
|
||||||
summary: buildMyWork.summarizeMyWork(queue),
|
|
||||||
}}));
|
|
||||||
"""
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
["node", "-e", script], check=True, capture_output=True, text=True
|
|
||||||
)
|
|
||||||
output = json.loads(result.stdout)
|
|
||||||
|
|
||||||
assert len(output["queue"]) == 2
|
|
||||||
assert [item["key"] for item in output["updates"]] == ["stackchain/web#8", "stackchain/api#7"]
|
|
||||||
assert output["updates"][0]["kind"] == "update"
|
|
||||||
assert output["updates"][1]["kind"] == "issue"
|
|
||||||
assert output["updates"][1]["url"].endswith("#issuecomment-9")
|
|
||||||
assert output["counts"] == {"all": 2, "issue": 1, "pull": 0, "review": 0, "update": 2}
|
|
||||||
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unread_update_correlation_distinguishes_issue_and_pull_with_same_number():
|
|
||||||
payload = {
|
|
||||||
"user": {"login": "timmy"},
|
|
||||||
"issues": [{"number": 7, "title": "Issue seven", "repository": "stackchain/api", "url": "https://forge.example/issues/7"}],
|
|
||||||
"pull_requests": [{"number": 7, "title": "Pull seven", "repository": "stackchain/api", "url": "https://forge.example/pulls/7"}],
|
|
||||||
"notifications": [{
|
|
||||||
"id": 42, "number": 7, "title": "Issue seven", "repository": "stackchain/api",
|
|
||||||
"subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/7#comment-1",
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
script = f"""
|
|
||||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
|
||||||
process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["node", "-e", script], check=True, capture_output=True, text=True
|
|
||||||
)
|
|
||||||
queue = json.loads(result.stdout)
|
|
||||||
|
|
||||||
issue = next(item for item in queue if item["kind"] == "issue")
|
|
||||||
pull = next(item for item in queue if item["kind"] == "pull")
|
|
||||||
assert issue["has_update"] is True
|
|
||||||
assert issue["url"].endswith("#comment-1")
|
|
||||||
assert pull["has_update"] is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -198,7 +125,6 @@ async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels(
|
||||||
assert 'data-work-filter="issue"' in html
|
assert 'data-work-filter="issue"' in html
|
||||||
assert 'data-work-filter="pull"' in html
|
assert 'data-work-filter="pull"' in html
|
||||||
assert 'data-work-filter="review"' in html
|
assert 'data-work-filter="review"' in html
|
||||||
assert 'data-work-filter="update"' in html
|
|
||||||
assert '.work-filter' in html and 'min-height: 44px' in html
|
assert '.work-filter' in html and 'min-height: 44px' in html
|
||||||
assert '.my-work-card' in html and 'min-height: 44px' in html
|
assert '.my-work-card' in html and 'min-height: 44px' in html
|
||||||
assert '<script src="static/my-work.js"></script>' in html
|
assert '<script src="static/my-work.js"></script>' in html
|
||||||
|
|
@ -216,8 +142,6 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||||
assert 'data-work-count="issue"' in html
|
assert 'data-work-count="issue"' in html
|
||||||
assert 'data-work-count="pull"' in html
|
assert 'data-work-count="pull"' in html
|
||||||
assert 'data-work-count="review"' in html
|
assert 'data-work-count="review"' in html
|
||||||
assert 'data-work-count="update"' in html
|
|
||||||
assert "['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)" in html
|
|
||||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user