Compare commits
139
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7073e1393 | ||
|
|
5b864c028e | ||
|
|
8ee7dbf79e | ||
|
|
db0354a6e5 | ||
|
|
a86de5cbf4 | ||
|
|
953f59fb19 | ||
|
|
36d7fb9ce8 | ||
|
|
955343a428 | ||
|
|
b51542363c | ||
|
|
54638d2686 | ||
|
|
db9e51e683 | ||
|
|
cafc062b9f | ||
|
|
ea7bd15721 | ||
|
|
3807e7afd1 | ||
|
|
0dc5c2c831 | ||
|
|
efb3ae3400 | ||
|
|
6e900e31e3 | ||
|
|
678005ece9 | ||
|
|
f34fc8b5f5 | ||
|
|
d75a55f3f7 | ||
|
|
30f92d18a2 | ||
|
|
e2a4b60143 | ||
|
|
331eb99c1e | ||
|
|
bd0ae619db | ||
|
|
173af9f698 | ||
|
|
3bf635fb89 | ||
|
|
d8937f0096 | ||
|
|
c4a9c76781 | ||
|
|
6c4ce5cf48 | ||
|
|
f662361b09 | ||
|
|
83788c6b82 | ||
|
|
329591ebd6 | ||
|
|
32d9d9413f | ||
|
|
b5830cf86f | ||
|
|
b7d4bd8137 | ||
|
|
26ffa1f1b3 | ||
|
|
de2f8d8fcc | ||
|
|
dce7373845 | ||
|
|
6be016d658 | ||
|
|
2891a0cfd8 | ||
|
|
f2540fb1db | ||
|
|
7f236c5a79 | ||
|
|
7c13bb1663 | ||
|
|
37dd7710b4 | ||
|
|
c21d968cc2 | ||
|
|
d86e86e1fb | ||
|
|
bfdc042e38 | ||
|
|
acc60d11e7 | ||
|
|
b055644be9 | ||
|
|
4602347a80 | ||
|
|
64b6ff6c5b | ||
|
|
0427b3079e | ||
|
|
f4e2969fec | ||
|
|
7d5d1181c0 | ||
|
|
b41d7ad03a | ||
|
|
b2de3b7eeb | ||
|
|
1b43116fe6 | ||
|
|
8f3bbde733 | ||
|
|
6e24d7db17 | ||
|
|
43a2804562 | ||
|
|
42b659e807 | ||
|
|
240d962ec3 | ||
|
|
2aba489036 | ||
|
|
10181aa019 | ||
|
|
2d492d9d2e | ||
|
|
1fe16c1342 | ||
|
|
68c2048724 | ||
|
|
fcdca7a37c | ||
|
|
77404cfd03 | ||
|
|
49ce2d9945 | ||
|
|
5f86b58b88 | ||
|
|
888c03e4c1 | ||
|
|
bce46843b3 | ||
|
|
89b14872e6 | ||
|
|
090a059c17 | ||
|
|
b2f8eaf89a | ||
|
|
8001933692 | ||
|
|
d9ad68b72b | ||
|
|
902db45741 | ||
|
|
af7f2ff92a | ||
|
|
dea3024f83 | ||
|
|
e46113a51a | ||
|
|
d8f2248600 | ||
|
|
d5ee4dcb29 | ||
|
|
19da7f47df | ||
|
|
069d8979fb | ||
|
|
13fcd42f13 | ||
|
|
5ca87c1b7c | ||
|
|
4bbe02b3f6 | ||
|
|
fb200ceb93 | ||
|
|
2fdb9fb897 | ||
|
|
b119dcf47d | ||
|
|
3e1ce3e72a | ||
|
|
36f500d58b | ||
|
|
a173182158 | ||
|
|
609df470d6 | ||
|
|
a92109b65f | ||
|
|
6678358e4c | ||
|
|
6a22bc212d | ||
|
|
60b6f1a74d | ||
|
|
f63c8c39b1 | ||
|
|
a6732b9c93 | ||
|
|
e8b5fd31a5 | ||
|
|
352d70e859 | ||
|
|
89c22c6091 | ||
|
|
e837934bec | ||
|
|
f0dc260c9b | ||
|
|
94cdd0f407 | ||
|
|
9b912fc837 | ||
|
|
d7bdbd3e18 | ||
|
|
4afa4032d7 | ||
|
|
582ab559c7 | ||
|
|
965b24a183 | ||
|
|
9ea8667264 | ||
|
|
9cdf9110fb | ||
|
|
360425d3ca | ||
|
|
324e1cab4e | ||
|
|
16e54b63f2 | ||
|
|
610b659fd4 | ||
|
|
a87e9d2db4 | ||
|
|
5a3f0128c4 | ||
|
|
1e17771840 | ||
|
|
286680c053 | ||
|
|
426c0645f9 | ||
|
|
9df3815417 | ||
|
|
0fc4b2684e | ||
|
|
20a6820d9a | ||
|
|
fef8480e5f | ||
|
|
19377ad487 | ||
|
|
bf935b0ad4 | ||
|
|
bcf1a5d5a0 | ||
|
|
1dd0e6e133 | ||
|
|
f2a14467b2 | ||
|
|
efcd1fb36e | ||
|
|
6bfc97dcdf | ||
|
|
8a406b6254 | ||
|
|
a2ffa9339d | ||
|
|
ce376aaa6e | ||
|
|
6da9847750 |
No files matched your search
@@ -25,5 +25,5 @@ jobs:
|
||||
- name: Pack frontend
|
||||
run: tar -czf frontend.tar.gz frontend
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: frontend, path: frontend.tar.gz }
|
||||
@@ -6,30 +6,35 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
tag-release:
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- name: Auto-tag
|
||||
run: |
|
||||
set -e
|
||||
TAG="0.0.${{ github.run_number }}"
|
||||
git tag "$TAG" || true
|
||||
git push origin "$TAG" || true
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
draft-rc:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tag-release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- name: Draft release
|
||||
run: |
|
||||
set -e
|
||||
TAG="${{ needs.tag-release.outputs.tag }}"
|
||||
gh release create "$TAG" --draft --title "RC $TAG" --notes "Automated release candidate for $TAG." || true
|
||||
- name: Create Gitea tag and release candidate
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="v0.1.0-rc.${{ github.run_number }}"
|
||||
TARGET="${{ github.sha }}"
|
||||
TAG_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/tags"
|
||||
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
printf '{"tag_name":"%s","target":"%s","message":"Automated release candidate %s"}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" > /tmp/tag.json
|
||||
curl --fail-with-body -sS -X POST "$TAG_URL" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/tag.json
|
||||
|
||||
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"Automated release candidate for commit %s.","draft":false,"prerelease":true}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
|
||||
curl --fail-with-body -sS -X POST "$RELEASE_URL" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/release.json
|
||||
@@ -2,6 +2,44 @@
|
||||
|
||||
Live AI-driven adaptive UI for the Stackchain AI Lab Gitea experience.
|
||||
|
||||
## Local quickstart
|
||||
|
||||
Python 3.11 or newer is recommended. Create an isolated environment and install
|
||||
the pinned dependencies:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
||||
token that can read dashboard data and update the authenticated user's
|
||||
notification threads, then start the API and bundled frontend:
|
||||
|
||||
```bash
|
||||
export GITEA_URL='https://forge.example.com'
|
||||
export GITEA_TOKEN='<read-scoped-token>'
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8000/` for the dashboard. To verify the backend and its
|
||||
Gitea connection directly, request
|
||||
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
|
||||
`user`, `repos`, `issues`, and `pull_requests`. Never commit the token or place
|
||||
it in a tracked configuration file.
|
||||
|
||||
For service monitoring, GET `/healthz` is a liveness check that confirms the
|
||||
API process is running and does not contact Gitea. GET `/readyz` is the
|
||||
readiness check: it validates the configured Gitea credentials and returns
|
||||
HTTP 503 with an error when Gitea is unavailable or authentication fails.
|
||||
|
||||
Run the test suite with:
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -q
|
||||
```
|
||||
|
||||
## Autonomous release worker
|
||||
|
||||
The deterministic issue-to-release engine lives at `src/release_engine.py`.
|
||||
@@ -20,16 +58,24 @@ python3 -m src.release_engine \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Execute one ticket:
|
||||
Execute one ticket after implementing and committing its change directly on
|
||||
the deterministic branch printed by `--dry-run`:
|
||||
|
||||
```bash
|
||||
export RELEASE_AGENT_COMMAND='codex exec "Implement Gitea issue #{issue_number}: {issue_title}. Follow the issue body and run tests."'
|
||||
export GITEA_TOKEN='<scoped-token>'
|
||||
export RELEASE_AGENT_COMMAND=true
|
||||
python3 -m src.release_engine \
|
||||
--repo stackchain/stackchain-dashboard \
|
||||
--agent timmy \
|
||||
--issue 19 \
|
||||
--test-command 'python3 -m pytest tests/ -q'
|
||||
```
|
||||
|
||||
Required secret: `GITEA_TOKEN`. Durable state defaults to
|
||||
`.release-engine/state.json`. Full behavior and safety gates are documented in
|
||||
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
|
||||
`GITEA_TOKEN` needs issue and repository write scopes. The engine verifies the
|
||||
claim, reruns the test command, pushes the branch, and opens a PR containing
|
||||
`Closes #19` plus test evidence. Replace `19` with the selected issue number;
|
||||
do not run without `--issue` when processing a preselected ticket.
|
||||
|
||||
Durable state defaults to `.release-engine/state.json`. Full behavior and
|
||||
safety gates are documented in
|
||||
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
|
||||
@@ -0,0 +1,10 @@
|
||||
(function (root, factory) {
|
||||
const filterCommands = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = filterCommands;
|
||||
if (root) root.filterCommands = filterCommands;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function filterCommands(commands, filter) {
|
||||
const term = String(filter || '').toLowerCase();
|
||||
return commands.filter(command => command.name.toLowerCase().includes(term));
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
function createContextPoller({
|
||||
fetchContext,
|
||||
onSnapshot,
|
||||
onError,
|
||||
isHidden = () => false,
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
intervalMs = 8000,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
cancelTimer();
|
||||
if (stopped || isHidden()) return;
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
refresh();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (stopped || isHidden()) return Promise.resolve(null);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
let request;
|
||||
try {
|
||||
request = fetchContext();
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
inFlight = Promise.resolve(request)
|
||||
.then((snapshot) => {
|
||||
onSnapshot(snapshot);
|
||||
return snapshot;
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
schedule();
|
||||
});
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
function setVisible(visible) {
|
||||
cancelTimer();
|
||||
if (!visible) return Promise.resolve(null);
|
||||
return refresh();
|
||||
}
|
||||
|
||||
return {
|
||||
start: refresh,
|
||||
refresh,
|
||||
setVisible,
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelTimer();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createContextPoller;
|
||||
}
|
||||
+818
-118
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
||||
(function (root, factory) {
|
||||
const renderMarkdown = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = renderMarkdown;
|
||||
root.renderMarkdown = renderMarkdown;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function escapeHtml(value) {
|
||||
return String(value || '').replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[character]);
|
||||
}
|
||||
|
||||
return function renderMarkdown(raw) {
|
||||
return escapeHtml(raw)
|
||||
.replace(/^#{1,6}\s.*$/gm, (line) => {
|
||||
const level = line.match(/^(#{1,6})/)[1].length;
|
||||
return '<h' + level + '>' + line.replace(/^#{1,6}\s/, '') + '</h' + level + '>';
|
||||
})
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/^-\s?(.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
||||
.replace(/\n/g, '<br>');
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
function buildMyWork(data) {
|
||||
const login = data.user?.login || '';
|
||||
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
||||
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
|
||||
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||
|
||||
const work = issues.concat(pulls).map((item) => {
|
||||
const labels = item.labels || [];
|
||||
const priorityLabel = labels.find((label) =>
|
||||
priorityLabels.includes(String(label).toLowerCase())
|
||||
);
|
||||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
return {
|
||||
...item,
|
||||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
is_assigned: assigned,
|
||||
has_update: false,
|
||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')),
|
||||
_priority: priorityLabel ? 0 : (isReview ? 2 : (assigned ? 3 : 4)),
|
||||
};
|
||||
});
|
||||
|
||||
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.notification_id = update.id;
|
||||
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,
|
||||
notification_id: update.id,
|
||||
reason: 'Unread update',
|
||||
_priority: 1,
|
||||
};
|
||||
work.push(item);
|
||||
byKey.set(subjectKind + ':' + key, item);
|
||||
});
|
||||
|
||||
return work.sort((left, right) =>
|
||||
left._priority - right._priority ||
|
||||
String(right.updated_at || '').localeCompare(String(left.updated_at || '')) ||
|
||||
left.key.localeCompare(right.key)
|
||||
).map(({ _priority, ...item }) => item);
|
||||
}
|
||||
|
||||
function acknowledgeNotification(items, notificationId) {
|
||||
return items.flatMap((item) => {
|
||||
if (item.notification_id !== notificationId) return [item];
|
||||
if (item.kind === 'update') return [];
|
||||
const { notification_id, ...acknowledged } = item;
|
||||
return [{ ...acknowledged, has_update: false }];
|
||||
});
|
||||
}
|
||||
|
||||
function createNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
||||
const pending = new Set();
|
||||
return {
|
||||
async acknowledge(items, notificationId) {
|
||||
if (pending.has(notificationId)) return false;
|
||||
pending.add(notificationId);
|
||||
onItems(acknowledgeNotification(items, notificationId));
|
||||
onStatus('Marking update read…');
|
||||
try {
|
||||
await markRead(notificationId);
|
||||
onStatus('Update marked read.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onItems(items);
|
||||
onStatus('Could not mark update read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending.delete(notificationId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function notificationIds(items) {
|
||||
return Array.from(new Set((items || [])
|
||||
.filter((item) => item && item.has_update && Number.isInteger(item.notification_id))
|
||||
.map((item) => item.notification_id)));
|
||||
}
|
||||
|
||||
function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
||||
let pending = false;
|
||||
return {
|
||||
async acknowledge(items, requestedIds) {
|
||||
if (pending) return false;
|
||||
const ids = Array.from(new Set((requestedIds || []).filter(Number.isInteger)));
|
||||
if (!ids.length) return false;
|
||||
pending = true;
|
||||
onStatus('Marking ' + ids.length + ' updates read…');
|
||||
try {
|
||||
const result = await markRead(ids);
|
||||
const marked = (result.marked || []).filter(Number.isInteger);
|
||||
const failed = (result.failed || []).filter(Number.isInteger);
|
||||
const updated = marked.reduce(
|
||||
(current, notificationId) => acknowledgeNotification(current, notificationId),
|
||||
items
|
||||
);
|
||||
onItems(updated);
|
||||
onStatus(failed.length ?
|
||||
marked.length + ' marked read · ' + failed.length + ' could not be updated — retry.' :
|
||||
marked.length + ' updates marked read.');
|
||||
return { marked, failed };
|
||||
} catch (_error) {
|
||||
onStatus('Could not mark updates read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
return {
|
||||
reset(next) {
|
||||
pagination = { ...pagination, ...(next || {}) };
|
||||
onPagination(pagination);
|
||||
},
|
||||
async loadMore(existing) {
|
||||
if (pending || !pagination.has_more) return false;
|
||||
pending = true;
|
||||
onStatus('Loading older updates…');
|
||||
try {
|
||||
const result = await load(pagination.page + 1);
|
||||
const byId = new Map((existing || [])
|
||||
.filter(item => item && Number.isInteger(item.id))
|
||||
.map(item => [item.id, item]));
|
||||
(result.items || []).forEach(item => {
|
||||
if (item && Number.isInteger(item.id) && !byId.has(item.id)) byId.set(item.id, item);
|
||||
});
|
||||
pagination = {
|
||||
page: result.page,
|
||||
total: result.total,
|
||||
has_more: result.has_more === true,
|
||||
};
|
||||
const loaded = Math.min(pagination.total, pagination.page * 50);
|
||||
onNotifications(Array.from(byId.values()));
|
||||
onPagination(pagination);
|
||||
onStatus(loaded + ' of ' + pagination.total + ' unread updates loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not load older updates. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose }) {
|
||||
let selected = null;
|
||||
let loadVersion = 0;
|
||||
let marking = false;
|
||||
|
||||
async function open(item) {
|
||||
selected = item;
|
||||
const version = ++loadVersion;
|
||||
onOpen(item);
|
||||
onStatus('Loading update…');
|
||||
try {
|
||||
const detail = await load(item.notification_id);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
onDetail(detail);
|
||||
onStatus('Update ready.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (selected === item && version === loadVersion) {
|
||||
onStatus('Could not load update. Retry or open it in Gitea.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
async markReadAndNext(items) {
|
||||
if (!selected || marking) return false;
|
||||
const current = selected;
|
||||
marking = true;
|
||||
onStatus('Marking update read…');
|
||||
try {
|
||||
await markRead(current.notification_id);
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item =>
|
||||
item && item.has_update && Number.isInteger(item.notification_id)
|
||||
);
|
||||
if (next) await open(next);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
} catch (_error) {
|
||||
onStatus('Could not mark update read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
marking = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function filterMyWork(items, selectedFilter) {
|
||||
if (selectedFilter === 'all') return items;
|
||||
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);
|
||||
}
|
||||
|
||||
function summarizeMyWork(items) {
|
||||
const updates = items.filter((item) => item.has_update).length;
|
||||
const reviews = items.filter((item) => item.is_review).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 assignedLabel = assigned + ' assigned';
|
||||
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
|
||||
}
|
||||
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
update: items.filter((item) => item.has_update).length,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
buildMyWork.notificationIds = notificationIds;
|
||||
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
||||
buildMyWork.createNotificationPager = createNotificationPager;
|
||||
buildMyWork.createNotificationReader = createNotificationReader;
|
||||
module.exports = buildMyWork;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
function createReviewController({ fetchJson }) {
|
||||
function endpoint(item) {
|
||||
const [owner, repo] = String(item.repository || '').split('/');
|
||||
if (!owner || !repo || !Number.isInteger(Number(item.number))) {
|
||||
throw new Error('This review link is invalid.');
|
||||
}
|
||||
return 'api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) +
|
||||
'/pulls/' + Number(item.number) + '/review';
|
||||
}
|
||||
|
||||
async function load(item) {
|
||||
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
|
||||
}
|
||||
|
||||
return { load };
|
||||
}
|
||||
|
||||
function diffLineClass(line) {
|
||||
if (line.startsWith('@@')) return 'hunk';
|
||||
if (line.startsWith('+')) return 'added';
|
||||
if (line.startsWith('-')) return 'removed';
|
||||
return 'context';
|
||||
}
|
||||
|
||||
function renderDiffFile(file, index, escapeHtml) {
|
||||
const panelId = 'review-diff-' + index;
|
||||
let preview;
|
||||
if (file.diff_available) {
|
||||
const lines = (file.diff_lines || []).map(line =>
|
||||
'<span class="review-diff-line ' + diffLineClass(String(line)) + '">' +
|
||||
escapeHtml(String(line)) + '</span>'
|
||||
).join('');
|
||||
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
|
||||
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
|
||||
'</pre>';
|
||||
} else {
|
||||
const message = file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.';
|
||||
preview = '<div class="review-diff-empty" id="' + panelId + '" hidden>' + message +
|
||||
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
|
||||
}
|
||||
const filename = escapeHtml(file.filename || 'Unknown file');
|
||||
return '<div class="review-file" data-review-filename="' + filename + '"><button class="review-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
|
||||
'<strong>' + filename + '</strong><span class="small">' +
|
||||
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / −' +
|
||||
Number(file.deletions || 0) + '</span></button>' + preview +
|
||||
'<label class="small" for="review-note-' + index + '">Note for ' + filename + '</label>' +
|
||||
'<textarea class="review-note" id="review-note-' + index + '" data-review-filename="' + filename + '" placeholder="Capture feedback for this file"></textarea>' +
|
||||
'<button class="review-mark" data-review-filename="' + filename + '" aria-pressed="false">Mark reviewed</button></div>';
|
||||
}
|
||||
|
||||
function toggleDiff(button, panel) {
|
||||
const expanded = button.getAttribute('aria-expanded') === 'true';
|
||||
button.setAttribute('aria-expanded', String(!expanded));
|
||||
panel.hidden = expanded;
|
||||
}
|
||||
|
||||
function createProgress({ storage, repository, number, headSha, files }) {
|
||||
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
|
||||
const key = 'stackchain.review-progress.v1:' + repository + '#' + number + '@' + headSha;
|
||||
const headKey = 'stackchain.review-progress.v1:' + repository + '#' + number + ':head';
|
||||
let reviewed = [];
|
||||
let newHead = false;
|
||||
try {
|
||||
const previousHead = storage.getItem(headKey);
|
||||
newHead = Boolean(previousHead && previousHead !== headSha);
|
||||
storage.setItem(headKey, headSha);
|
||||
const saved = JSON.parse(storage.getItem(key) || '[]');
|
||||
if (Array.isArray(saved)) reviewed = filenames.filter(filename => saved.includes(filename));
|
||||
} catch (error) {
|
||||
reviewed = [];
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const pending = filenames.find(filename => !reviewed.includes(filename)) || null;
|
||||
return {
|
||||
reviewed: [...reviewed],
|
||||
reviewedCount: reviewed.length,
|
||||
total: filenames.length,
|
||||
nextFilename: pending,
|
||||
...(newHead ? { newHead: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function markReviewed(filename) {
|
||||
if (filenames.includes(filename) && !reviewed.includes(filename)) {
|
||||
reviewed.push(filename);
|
||||
reviewed = filenames.filter(item => reviewed.includes(item));
|
||||
try { storage.setItem(key, JSON.stringify(reviewed)); } catch (error) { /* local progress remains usable */ }
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, markReviewed };
|
||||
}
|
||||
|
||||
function createDraft({ storage, repository, number, headSha, files }) {
|
||||
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
|
||||
const key = 'stackchain.review-draft.v1:' + repository + '#' + number + '@' + headSha;
|
||||
let draft = { notes: {}, summary: '', decision: 'comment' };
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || '{}');
|
||||
if (saved && typeof saved === 'object') {
|
||||
draft.notes = Object.fromEntries(filenames
|
||||
.filter(filename => typeof saved.notes?.[filename] === 'string' && saved.notes[filename])
|
||||
.map(filename => [filename, saved.notes[filename]]));
|
||||
if (typeof saved.summary === 'string') draft.summary = saved.summary;
|
||||
if (['comment', 'approve', 'request_changes'].includes(saved.decision)) {
|
||||
draft.decision = saved.decision;
|
||||
}
|
||||
}
|
||||
} catch (error) { /* start with an empty in-memory draft */ }
|
||||
|
||||
function persist() {
|
||||
try { storage.setItem(key, JSON.stringify(draft)); } catch (error) { /* draft remains usable */ }
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { notes: { ...draft.notes }, summary: draft.summary, decision: draft.decision };
|
||||
}
|
||||
|
||||
function setNote(filename, note) {
|
||||
if (!filenames.includes(filename)) return snapshot();
|
||||
if (String(note)) draft.notes[filename] = String(note);
|
||||
else delete draft.notes[filename];
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setSummary(summary) {
|
||||
draft.summary = String(summary || '');
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setDecision(decision) {
|
||||
if (['comment', 'approve', 'request_changes'].includes(decision)) {
|
||||
draft.decision = decision;
|
||||
persist();
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, setNote, setSummary, setDecision };
|
||||
}
|
||||
|
||||
function formatFeedback(draft, files) {
|
||||
const decisions = {
|
||||
comment: 'Comment',
|
||||
approve: 'Approve',
|
||||
request_changes: 'Request changes',
|
||||
};
|
||||
const sections = [
|
||||
'## Intended decision\n' + (decisions[draft.decision] || decisions.comment),
|
||||
];
|
||||
if (String(draft.summary || '').trim()) {
|
||||
sections.push('## Summary\n' + String(draft.summary).trim());
|
||||
}
|
||||
const fileNotes = (files || []).flatMap(file => {
|
||||
const filename = file && file.filename;
|
||||
const note = filename && String(draft.notes?.[filename] || '').trim();
|
||||
if (!note) return [];
|
||||
return ['### `' + String(filename).replaceAll('`', '\\`') + '`\n' + note];
|
||||
});
|
||||
if (fileNotes.length) sections.push('## File notes\n' + fileNotes.join('\n\n'));
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
async function copyAndContinue({ text, url, copy, open, fallback }) {
|
||||
const destination = open();
|
||||
try {
|
||||
await copy(text);
|
||||
} catch (error) {
|
||||
destination?.close?.();
|
||||
fallback(text);
|
||||
return { copied: false, opened: false };
|
||||
}
|
||||
if (!destination) {
|
||||
fallback(text);
|
||||
return { copied: true, opened: false };
|
||||
}
|
||||
destination.location.href = url;
|
||||
return { copied: true, opened: true };
|
||||
}
|
||||
|
||||
createReviewController.renderDiffFile = renderDiffFile;
|
||||
createReviewController.toggleDiff = toggleDiff;
|
||||
createReviewController.createProgress = createProgress;
|
||||
createReviewController.createDraft = createDraft;
|
||||
createReviewController.formatFeedback = formatFeedback;
|
||||
createReviewController.copyAndContinue = copyAndContinue;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createReviewController;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
function renderRepoMix(element, data) {
|
||||
const repos = data.repos || [];
|
||||
const issues = (data.issues || []).filter((issue) => issue.state === 'open');
|
||||
const pullRequests = data.pull_requests || [];
|
||||
element.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + pullRequests.length + '</div></div>';
|
||||
}
|
||||
|
||||
async function updateRepoMix(element, fetchContext = fetch) {
|
||||
try {
|
||||
const response = await fetchContext('api/v1/context', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
renderRepoMix(element, await response.json());
|
||||
} catch (error) {
|
||||
element.innerHTML = '<div class="muted">Widget unavailable.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
updateRepoMix.renderRepoMix = renderRepoMix;
|
||||
module.exports = updateRepoMix;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.133.1
|
||||
httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
pytest==9.1.1
|
||||
uvicorn==0.41.0
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+386
-6
@@ -1,10 +1,16 @@
|
||||
import asyncio
|
||||
import os
|
||||
import shlex
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000").rstrip("/")
|
||||
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
|
||||
REVIEW_DIFF_MAX_BYTES = 64 * 1024
|
||||
REVIEW_DIFF_MAX_LINES = 400
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def _auth() -> dict[str, str]:
|
||||
@@ -14,11 +20,88 @@ def _auth() -> dict[str, str]:
|
||||
return headers
|
||||
|
||||
|
||||
def start_client(**kwargs) -> httpx.AsyncClient:
|
||||
"""Create the application-lifetime Gitea transport."""
|
||||
global _client
|
||||
_client = httpx.AsyncClient(base_url=GITEA_URL, timeout=10, **kwargs)
|
||||
return _client
|
||||
|
||||
|
||||
def _get_client() -> httpx.AsyncClient:
|
||||
if _client is None or _client.is_closed:
|
||||
return start_client()
|
||||
return _client
|
||||
|
||||
|
||||
async def stop_client() -> None:
|
||||
global _client
|
||||
if _client is not None and not _client.is_closed:
|
||||
await _client.aclose()
|
||||
|
||||
|
||||
async def fetch(path: str) -> Any:
|
||||
async with httpx.AsyncClient(base_url=GITEA_URL, timeout=10) as client:
|
||||
r = await client.get(f"/api/v1/{path}", headers=_auth())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
r = await _get_client().get(f"/api/v1/{path}", headers=_auth())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def fetch_text(path: str, max_bytes: int) -> tuple[str, bool]:
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
truncated = False
|
||||
async with _get_client().stream(
|
||||
"GET", f"/api/v1/{path}", headers={**_auth(), "Accept": "text/plain"}
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
async for chunk in response.aiter_bytes():
|
||||
remaining = max_bytes - size
|
||||
if len(chunk) > remaining:
|
||||
chunks.append(chunk[:remaining])
|
||||
truncated = True
|
||||
break
|
||||
chunks.append(chunk)
|
||||
size += len(chunk)
|
||||
return b"".join(chunks).decode("utf-8", errors="replace"), truncated
|
||||
|
||||
|
||||
def _diff_previews(diff: str, stream_truncated: bool) -> dict[str, dict]:
|
||||
previews: dict[str, dict] = {}
|
||||
current: dict | None = None
|
||||
in_hunk = False
|
||||
remaining = REVIEW_DIFF_MAX_LINES
|
||||
for line in diff.splitlines():
|
||||
if line.startswith("diff --git "):
|
||||
try:
|
||||
target = shlex.split(line)[3]
|
||||
filename = target[2:] if target.startswith("b/") else target
|
||||
except (IndexError, ValueError):
|
||||
current = None
|
||||
continue
|
||||
current = {
|
||||
"diff_lines": [],
|
||||
"diff_available": False,
|
||||
"diff_binary": False,
|
||||
"diff_truncated": stream_truncated,
|
||||
}
|
||||
previews[filename] = current
|
||||
in_hunk = False
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
if line.startswith("Binary files ") or line == "GIT binary patch":
|
||||
current["diff_binary"] = True
|
||||
in_hunk = False
|
||||
continue
|
||||
if line.startswith("@@"):
|
||||
in_hunk = True
|
||||
if in_hunk and not line.startswith("\\ No newline at end of file"):
|
||||
if remaining:
|
||||
current["diff_lines"].append(line)
|
||||
current["diff_available"] = True
|
||||
remaining -= 1
|
||||
else:
|
||||
current["diff_truncated"] = True
|
||||
return previews
|
||||
|
||||
|
||||
async def current_user() -> dict:
|
||||
@@ -30,8 +113,305 @@ async def repos() -> list[dict]:
|
||||
|
||||
|
||||
async def issues() -> list[dict]:
|
||||
return await fetch("user/issues?limit=50&type=all")
|
||||
return await fetch(
|
||||
"repos/issues/search?state=open&assigned=true&type=issues&limit=50"
|
||||
)
|
||||
|
||||
|
||||
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 ""
|
||||
|
||||
|
||||
def _normalize_notifications(threads: Any) -> list[dict]:
|
||||
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 notifications() -> dict:
|
||||
return await notification_page(1)
|
||||
|
||||
|
||||
async def notification_page(page: int, limit: int = 50) -> dict:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/notifications?status-types=unread&limit={limit}&page={page}",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
items = _normalize_notifications(response.json())
|
||||
try:
|
||||
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
|
||||
except (TypeError, ValueError):
|
||||
total = len(items)
|
||||
return {
|
||||
"items": items,
|
||||
"page": page,
|
||||
"total": total,
|
||||
"has_more": page * limit < total,
|
||||
}
|
||||
|
||||
|
||||
async def mark_notification_read(thread_id: int) -> None:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/notifications/threads/{thread_id}?to-status=read",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def _gitea_api_path(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
parsed = urlsplit(value)
|
||||
configured = urlsplit(GITEA_URL)
|
||||
prefix = "/api/v1/"
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or parsed.netloc != configured.netloc
|
||||
or not parsed.path.startswith(prefix)
|
||||
):
|
||||
return ""
|
||||
return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "")
|
||||
|
||||
|
||||
async def notification_detail(thread_id: int) -> 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_value = thread.get("repository")
|
||||
repository = repository_value if isinstance(repository_value, dict) else {}
|
||||
subject_value = thread.get("subject")
|
||||
subject = subject_value if isinstance(subject_value, dict) else {}
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
comment_path = _gitea_api_path(subject.get("latest_comment_url"))
|
||||
subject_detail = await fetch(subject_path) if subject_path else {}
|
||||
comment = await fetch(comment_path) if comment_path else {}
|
||||
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
|
||||
comment = comment if isinstance(comment, dict) else {}
|
||||
user_value = comment.get("user")
|
||||
user = user_value if isinstance(user_value, dict) else {}
|
||||
subject_url = _safe_web_url(subject.get("html_url"))
|
||||
latest_url = _safe_web_url(comment.get("html_url")) or _safe_web_url(
|
||||
subject.get("latest_comment_html_url")
|
||||
)
|
||||
return {
|
||||
"id": thread_id,
|
||||
"repository": repository.get("full_name", "")
|
||||
if isinstance(repository.get("full_name"), str)
|
||||
else "",
|
||||
"title": subject.get("title", "")
|
||||
if isinstance(subject.get("title"), str)
|
||||
else "",
|
||||
"subject_type": subject.get("type", "Update")
|
||||
if isinstance(subject.get("type"), str)
|
||||
else "Update",
|
||||
"state": subject.get("state", "")
|
||||
if isinstance(subject.get("state"), str)
|
||||
else "",
|
||||
"url": latest_url or subject_url,
|
||||
"subject_body": subject_detail.get("body", "")
|
||||
if isinstance(subject_detail.get("body"), str)
|
||||
else "",
|
||||
"latest_comment": {
|
||||
"author": user.get("login", "")
|
||||
if isinstance(user.get("login"), str)
|
||||
else "",
|
||||
"body": comment.get("body", "")
|
||||
if isinstance(comment.get("body"), str)
|
||||
else "",
|
||||
"created_at": comment.get("created_at", "")
|
||||
if isinstance(comment.get("created_at"), str)
|
||||
else "",
|
||||
"url": latest_url,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def pull_requests() -> list[dict]:
|
||||
return await fetch("user/pulls?limit=50")
|
||||
assigned, review_requested = await asyncio.gather(
|
||||
fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"),
|
||||
fetch(
|
||||
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50"
|
||||
),
|
||||
)
|
||||
merged: dict[int, dict] = {}
|
||||
for reason, pulls in (
|
||||
("assigned_to_me", assigned or []),
|
||||
("review_requested", review_requested or []),
|
||||
):
|
||||
for pull in pulls:
|
||||
identity = pull.get("id")
|
||||
if identity not in merged:
|
||||
merged[identity] = {**pull, "work_reasons": []}
|
||||
merged[identity]["work_reasons"].append(reason)
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
async def is_requested_review(repository: str, number: int) -> bool:
|
||||
pulls = await fetch(
|
||||
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50"
|
||||
)
|
||||
return any(
|
||||
isinstance(pull, dict)
|
||||
and pull.get("number") == number
|
||||
and isinstance(pull.get("repository"), dict)
|
||||
and pull["repository"].get("full_name") == repository
|
||||
for pull in (pulls or [])
|
||||
)
|
||||
|
||||
|
||||
async def pull_review_detail(repository: str, number: int) -> dict:
|
||||
base = f"repos/{repository}/pulls/{number}"
|
||||
pull = await fetch(base)
|
||||
if not isinstance(pull, dict):
|
||||
raise ValueError("Gitea pull request response was not an object")
|
||||
head_value = pull.get("head")
|
||||
head: dict = head_value if isinstance(head_value, dict) else {}
|
||||
sha_value = head.get("sha")
|
||||
sha = sha_value if isinstance(sha_value, str) else ""
|
||||
files, status, reviews, diff_result = await asyncio.gather(
|
||||
fetch(f"{base}/files"),
|
||||
fetch(f"repos/{repository}/commits/{sha}/status"),
|
||||
fetch(f"{base}/reviews"),
|
||||
fetch_text(
|
||||
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
|
||||
),
|
||||
)
|
||||
diff, diff_truncated = diff_result
|
||||
previews = _diff_previews(diff, diff_truncated)
|
||||
user_value = pull.get("user")
|
||||
user: dict = user_value if isinstance(user_value, dict) else {}
|
||||
normalized_files = [
|
||||
{
|
||||
"filename": file["filename"],
|
||||
"status": file.get("status") or "changed",
|
||||
"additions": file.get("additions") or 0,
|
||||
"deletions": file.get("deletions") or 0,
|
||||
**previews.get(
|
||||
file["filename"],
|
||||
{
|
||||
"diff_lines": [],
|
||||
"diff_available": False,
|
||||
"diff_binary": False,
|
||||
"diff_truncated": diff_truncated,
|
||||
},
|
||||
),
|
||||
}
|
||||
for file in (files if isinstance(files, list) else [])[:100]
|
||||
if isinstance(file, dict) and isinstance(file.get("filename"), str)
|
||||
]
|
||||
normalized_reviews = [
|
||||
{
|
||||
"user": {
|
||||
"login": (
|
||||
review["user"].get("login", "")
|
||||
if isinstance(review.get("user"), dict)
|
||||
else ""
|
||||
)
|
||||
},
|
||||
"state": review.get("state") or "COMMENT",
|
||||
"body": review.get("body") or "",
|
||||
}
|
||||
for review in (reviews if isinstance(reviews, list) else [])[:50]
|
||||
if isinstance(review, dict)
|
||||
]
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": pull.get("title", ""),
|
||||
"body": pull.get("body") or "",
|
||||
"url": pull.get("html_url", ""),
|
||||
"author": user.get("login", ""),
|
||||
"head_sha": sha,
|
||||
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
|
||||
"files": normalized_files,
|
||||
"reviews": normalized_reviews,
|
||||
}
|
||||
|
||||
|
||||
async def activity_events(user: dict | None = None) -> list[dict]:
|
||||
if user is None:
|
||||
user = await current_user()
|
||||
events = await fetch(f"users/{user['login']}/activities/feeds?limit=20")
|
||||
if events is None:
|
||||
events = []
|
||||
elif not isinstance(events, list):
|
||||
raise ValueError("Gitea activity feed response was not a list")
|
||||
return [
|
||||
{
|
||||
"type": (
|
||||
event.get("op_type")
|
||||
if isinstance(event.get("op_type"), str) and event.get("op_type")
|
||||
else "activity"
|
||||
),
|
||||
"actor": (
|
||||
event.get("act_user")
|
||||
if isinstance(event.get("act_user"), dict)
|
||||
else {}
|
||||
),
|
||||
"repo": (
|
||||
event.get("repo")
|
||||
if isinstance(event.get("repo"), dict)
|
||||
else {}
|
||||
),
|
||||
"created_at": (
|
||||
event.get("created")
|
||||
if isinstance(event.get("created"), str)
|
||||
else ""
|
||||
),
|
||||
}
|
||||
for event in (events or [])
|
||||
if isinstance(event, dict)
|
||||
]
|
||||
+622
-33
@@ -1,14 +1,131 @@
|
||||
from fastapi import FastAPI
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, PositiveInt
|
||||
|
||||
from src.gitea_proxy import current_user, repos, issues, pull_requests
|
||||
from src.models import User, Repo, Issue, PullRequest
|
||||
from src import gitea_proxy
|
||||
from src.gitea_proxy import (
|
||||
activity_events,
|
||||
current_user,
|
||||
is_requested_review,
|
||||
issues,
|
||||
mark_notification_read,
|
||||
notification_detail,
|
||||
notifications,
|
||||
pull_requests,
|
||||
pull_review_detail,
|
||||
repos,
|
||||
)
|
||||
from src.models import Issue, PullRequest, Repo, User
|
||||
from src.suggestion_engine import compute
|
||||
from src.views import router as frontend_router
|
||||
|
||||
app = FastAPI(title="Stackchain Dashboard")
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
global _live_snapshot_task
|
||||
gitea_proxy.start_client()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
task = _live_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
try:
|
||||
await gitea_proxy.stop_client()
|
||||
finally:
|
||||
if _live_snapshot_task is task:
|
||||
_live_snapshot_task = None
|
||||
|
||||
|
||||
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
|
||||
CONTEXT_TIMEOUT_SECONDS = 5.0
|
||||
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
|
||||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
|
||||
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
|
||||
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
|
||||
NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0
|
||||
BULK_NOTIFICATION_CONCURRENCY = 5
|
||||
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
|
||||
LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
|
||||
LIVE_SNAPSHOT_RETRY_BASE_SECONDS = 5.0
|
||||
LIVE_SNAPSHOT_RETRY_MAX_SECONDS = 60.0
|
||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||
_live_snapshot_task: asyncio.Task | None = None
|
||||
_live_snapshot_value: dict | None = None
|
||||
_live_snapshot_created_at: float | None = None
|
||||
_live_snapshot_failure_count = 0
|
||||
_live_snapshot_retry_at: float | None = None
|
||||
_read_notification_ids: set[int] = set()
|
||||
|
||||
|
||||
class ContextPayloadError(ValueError):
|
||||
"""Raised when Gitea returns a structurally invalid context payload."""
|
||||
|
||||
|
||||
class ReadinessPayloadError(ValueError):
|
||||
"""Raised when Gitea returns a structurally invalid readiness payload."""
|
||||
|
||||
|
||||
class NotificationReadBatch(BaseModel):
|
||||
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||
user_model = User(
|
||||
id=user_data["id"],
|
||||
login=user_data["login"],
|
||||
full_name=user_data.get("full_name") or "",
|
||||
email=user_data.get("email") or "",
|
||||
)
|
||||
repo_models = [
|
||||
Repo(
|
||||
id=r["id"], name=r["name"], full_name=r["full_name"],
|
||||
description=r.get("description") or "", url=r["html_url"],
|
||||
updated_at=r.get("updated_at", ""),
|
||||
)
|
||||
for r in (repo_data or [])[:50]
|
||||
if isinstance(r, dict)
|
||||
and all(field in r for field in ("id", "name", "full_name", "html_url"))
|
||||
]
|
||||
issue_models = [
|
||||
Issue(
|
||||
id=i["id"], number=i["number"], title=i["title"], state=i["state"],
|
||||
labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)],
|
||||
assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, dict)],
|
||||
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
|
||||
updated_at=i.get("updated_at") or "", url=i["html_url"],
|
||||
)
|
||||
for i in (issues_data or [])[:50]
|
||||
if isinstance(i, dict)
|
||||
and all(field in i for field in ("id", "number", "title", "state", "html_url"))
|
||||
]
|
||||
pr_models = [
|
||||
PullRequest(
|
||||
id=p["id"], number=p["number"], title=p["title"], state=p["state"],
|
||||
user=p["user"].get("login", "") if isinstance(p.get("user"), dict) else "",
|
||||
labels=[label.get("name", "") for label in (p.get("labels") or []) if isinstance(label, dict)],
|
||||
assignees=[assignee.get("login", "") for assignee in (p.get("assignees") or []) if isinstance(assignee, dict)],
|
||||
work_reasons=[reason for reason in (p.get("work_reasons") or []) if reason in ("assigned_to_me", "review_requested")],
|
||||
repository=p["repository"].get("full_name", "") if isinstance(p.get("repository"), dict) else "",
|
||||
updated_at=p.get("updated_at") or "", url=p["html_url"],
|
||||
)
|
||||
for p in (prs_data or [])[:50]
|
||||
if isinstance(p, dict)
|
||||
and all(field in p for field in ("id", "number", "title", "state", "html_url"))
|
||||
]
|
||||
return compute(user_model, repo_models, issue_models, pr_models).model_dump()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -18,17 +135,94 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||
app.include_router(frontend_router)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def prevent_live_api_caching(request, call_next):
|
||||
response = await call_next(request)
|
||||
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live"} or (
|
||||
request.url.path.startswith("/api/v1/repos/")
|
||||
and request.url.path.endswith("/review")
|
||||
) or request.url.path.startswith("/api/v1/notifications"):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def health() -> dict[str, str]:
|
||||
"""Return process liveness without depending on Gitea."""
|
||||
return {"status": "ok", "service": "stackchain-dashboard"}
|
||||
|
||||
|
||||
@app.get("/readyz")
|
||||
async def readiness():
|
||||
"""Return readiness after verifying the configured Gitea connection."""
|
||||
try:
|
||||
user = await asyncio.wait_for(
|
||||
current_user(), timeout=READINESS_TIMEOUT_SECONDS
|
||||
)
|
||||
if not isinstance(user, dict) or not user.get("login"):
|
||||
raise ReadinessPayloadError(
|
||||
"Gitea current-user response did not include a login"
|
||||
)
|
||||
except Exception as exc:
|
||||
timed_out = isinstance(exc, TimeoutError)
|
||||
error_message = (
|
||||
f"Gitea readiness check timed out after {READINESS_TIMEOUT_SECONDS:g}s"
|
||||
if timed_out
|
||||
else (
|
||||
str(exc)
|
||||
if isinstance(exc, ReadinessPayloadError)
|
||||
else "Gitea readiness check is temporarily unavailable"
|
||||
)
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "not_ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"error": error_message,
|
||||
},
|
||||
status_code=503,
|
||||
headers={
|
||||
"Retry-After": str(max(1, math.ceil(READINESS_TIMEOUT_SECONDS)))
|
||||
} if timed_out else None,
|
||||
)
|
||||
return {
|
||||
"status": "ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"gitea_user": user["login"],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/context")
|
||||
async def context() -> JSONResponse:
|
||||
try:
|
||||
user_data = await current_user()
|
||||
repo_data = await repos()
|
||||
issues_data = await issues()
|
||||
prs_data = await pull_requests()
|
||||
user_data, repo_data, issues_data, prs_data = await asyncio.wait_for(
|
||||
asyncio.gather(current_user(), repos(), issues(), pull_requests()),
|
||||
timeout=CONTEXT_TIMEOUT_SECONDS,
|
||||
)
|
||||
if not isinstance(user_data, dict):
|
||||
raise ContextPayloadError("Gitea current-user response was not an object")
|
||||
if not all(field in user_data for field in ("id", "login")):
|
||||
raise ContextPayloadError(
|
||||
"Gitea current-user response did not include id and login"
|
||||
)
|
||||
if any(
|
||||
data is not None and not isinstance(data, list)
|
||||
for data in (repo_data, issues_data, prs_data)
|
||||
):
|
||||
raise ContextPayloadError("Gitea collection response was not a list")
|
||||
except Exception as e:
|
||||
if isinstance(e, TimeoutError):
|
||||
error_message = (
|
||||
f"Gitea context request timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"
|
||||
)
|
||||
elif isinstance(e, ContextPayloadError):
|
||||
error_message = str(e)
|
||||
else:
|
||||
error_message = "Gitea context is temporarily unavailable"
|
||||
return JSONResponse({
|
||||
"user": {"id": None, "login": "timmy", "full_name": "Timmy Jr", "email": ""},
|
||||
"repos": [],
|
||||
@@ -39,30 +233,425 @@ async def context() -> JSONResponse:
|
||||
{"panel": "auth", "action": "connect", "target": "stackchain-dashboard backend", "priority": "high"},
|
||||
{"panel": "gitea", "action": "set GITEA_URL + GITEA_TOKEN", "target": "/root/stackchain-dashboard/.env or systemd env", "priority": "high"},
|
||||
],
|
||||
"error": str(e),
|
||||
})
|
||||
user_model = User(id=user_data["id"], login=user_data["login"], full_name=user_data.get("full_name", ""), email=user_data.get("email", ""))
|
||||
repo_models = [
|
||||
Repo(id=r["id"], name=r["name"], full_name=r["full_name"], description=r.get("description", ""), url=r["html_url"], updated_at=r.get("updated_at", ""))
|
||||
for r in repo_data[:50]
|
||||
if isinstance(r, dict)
|
||||
"error": error_message,
|
||||
}, headers={
|
||||
"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))
|
||||
} if isinstance(e, TimeoutError) else None)
|
||||
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
|
||||
|
||||
|
||||
async def _load_context_for_user(user_data: dict) -> dict:
|
||||
repo_data, issues_data, prs_data = await asyncio.gather(
|
||||
repos(), issues(), pull_requests()
|
||||
)
|
||||
if not all(field in user_data for field in ("id", "login")):
|
||||
raise ContextPayloadError("Gitea current-user response did not include id and login")
|
||||
if any(
|
||||
data is not None and not isinstance(data, list)
|
||||
for data in (repo_data, issues_data, prs_data)
|
||||
):
|
||||
raise ContextPayloadError("Gitea collection response was not a list")
|
||||
return _context_payload(user_data, repo_data, issues_data, prs_data)
|
||||
|
||||
|
||||
async def _build_live_snapshot() -> dict:
|
||||
user_data = await current_user()
|
||||
if not isinstance(user_data, dict) or not user_data.get("login"):
|
||||
raise ContextPayloadError("Gitea current-user response was invalid")
|
||||
context_result, events_result, notifications_result = await asyncio.gather(
|
||||
_load_context_for_user(user_data),
|
||||
activity_events(user_data),
|
||||
notifications(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
context_ok = not isinstance(context_result, BaseException)
|
||||
events_ok = not isinstance(events_result, BaseException)
|
||||
notifications_ok = not isinstance(notifications_result, BaseException)
|
||||
notification_items = notifications_result
|
||||
notification_pagination = None
|
||||
if notifications_ok and isinstance(notifications_result, dict):
|
||||
notification_items = notifications_result.get("items")
|
||||
notification_pagination = {
|
||||
"page": notifications_result.get("page", 1),
|
||||
"total": notifications_result.get("total", 0),
|
||||
"has_more": notifications_result.get("has_more") is True,
|
||||
}
|
||||
notifications_ok = isinstance(notification_items, list)
|
||||
return {
|
||||
"context": context_result if context_ok else None,
|
||||
"events": events_result if events_ok else None,
|
||||
"notifications": notification_items if notifications_ok else None,
|
||||
"notification_pagination": notification_pagination if notifications_ok else None,
|
||||
"sections": {
|
||||
"context": "fresh" if context_ok else "temporarily unavailable",
|
||||
"events": "fresh" if events_ok else "temporarily unavailable",
|
||||
"notifications": "fresh" if notifications_ok else "temporarily unavailable",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _build_live_snapshot_before_deadline() -> dict:
|
||||
async with asyncio.timeout(CONTEXT_TIMEOUT_SECONDS):
|
||||
return await _build_live_snapshot()
|
||||
|
||||
|
||||
def _record_live_snapshot_failure() -> None:
|
||||
global _live_snapshot_failure_count, _live_snapshot_retry_at
|
||||
_live_snapshot_failure_count += 1
|
||||
delay = min(
|
||||
LIVE_SNAPSHOT_RETRY_MAX_SECONDS,
|
||||
LIVE_SNAPSHOT_RETRY_BASE_SECONDS * (2 ** (_live_snapshot_failure_count - 1)),
|
||||
)
|
||||
_live_snapshot_retry_at = time.monotonic() + delay
|
||||
|
||||
|
||||
def _merge_live_snapshot(previous: dict | None, refreshed: dict) -> dict:
|
||||
if previous is None:
|
||||
return refreshed
|
||||
merged = dict(refreshed)
|
||||
sections = dict(refreshed.get("sections") or {})
|
||||
for section in ("context", "events", "notifications"):
|
||||
if sections.get(section) == "fresh":
|
||||
continue
|
||||
if previous.get(section) is not None:
|
||||
merged[section] = previous[section]
|
||||
sections[section] = "stale"
|
||||
if section == "notifications":
|
||||
merged["notification_pagination"] = previous.get(
|
||||
"notification_pagination"
|
||||
)
|
||||
merged["sections"] = sections
|
||||
return merged
|
||||
|
||||
|
||||
async def _refresh_live_snapshot() -> dict:
|
||||
global _live_snapshot_value, _live_snapshot_created_at
|
||||
global _live_snapshot_failure_count, _live_snapshot_retry_at
|
||||
try:
|
||||
result = await _build_live_snapshot_before_deadline()
|
||||
except Exception:
|
||||
_record_live_snapshot_failure()
|
||||
raise
|
||||
result = _merge_live_snapshot(_live_snapshot_value, result)
|
||||
result = _without_read_notifications(result)
|
||||
_live_snapshot_value = result
|
||||
_live_snapshot_created_at = time.monotonic()
|
||||
if any(state != "fresh" for state in result.get("sections", {}).values()):
|
||||
_record_live_snapshot_failure()
|
||||
else:
|
||||
_live_snapshot_failure_count = 0
|
||||
_live_snapshot_retry_at = None
|
||||
return result
|
||||
|
||||
|
||||
def _consume_live_snapshot_failure(task: asyncio.Task) -> None:
|
||||
if task.cancelled():
|
||||
return
|
||||
task.exception()
|
||||
|
||||
|
||||
def _start_live_snapshot_refresh() -> asyncio.Task:
|
||||
global _live_snapshot_task
|
||||
if _live_snapshot_task is None or _live_snapshot_task.done():
|
||||
_live_snapshot_task = asyncio.create_task(_refresh_live_snapshot())
|
||||
_live_snapshot_task.add_done_callback(_consume_live_snapshot_failure)
|
||||
return _live_snapshot_task
|
||||
|
||||
|
||||
def _live_snapshot_payload(value: dict, *, stale: bool, revalidating: bool) -> dict:
|
||||
payload = dict(value)
|
||||
age = (
|
||||
max(0.0, time.monotonic() - _live_snapshot_created_at)
|
||||
if _live_snapshot_created_at is not None
|
||||
else 0.0
|
||||
)
|
||||
retry_in_seconds = (
|
||||
max(0.0, _live_snapshot_retry_at - time.monotonic())
|
||||
if _live_snapshot_retry_at is not None
|
||||
else 0.0
|
||||
)
|
||||
payload["freshness"] = {
|
||||
"age_seconds": round(age, 3),
|
||||
"fresh_for_seconds": LIVE_SNAPSHOT_FRESHNESS_SECONDS,
|
||||
"stale": stale,
|
||||
"revalidating": revalidating,
|
||||
"degraded": _live_snapshot_retry_at is not None,
|
||||
"last_refresh_failed": _live_snapshot_retry_at is not None,
|
||||
"retry_in_seconds": math.ceil(retry_in_seconds),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def _remove_notification_from_live_snapshot(thread_id: int) -> None:
|
||||
global _live_snapshot_value, _read_notification_ids
|
||||
_read_notification_ids = _read_notification_ids | {thread_id}
|
||||
if _live_snapshot_value is None:
|
||||
return
|
||||
retained_notifications = _live_snapshot_value.get("notifications")
|
||||
if not isinstance(retained_notifications, list):
|
||||
return
|
||||
updated = dict(_live_snapshot_value)
|
||||
updated["notifications"] = [
|
||||
notification
|
||||
for notification in retained_notifications
|
||||
if not isinstance(notification, dict) or notification.get("id") != thread_id
|
||||
]
|
||||
issue_models = [
|
||||
Issue(id=i["id"], number=i["number"], title=i["title"], state=i["state"], labels=[l.get("name", "") for l in i.get("labels", [])], assignees=[a.get("login", "") for a in i.get("assignees", [])], url=i["html_url"])
|
||||
for i in issues_data[:50]
|
||||
if isinstance(i, dict)
|
||||
_live_snapshot_value = updated
|
||||
|
||||
|
||||
def _without_read_notifications(snapshot: dict) -> dict:
|
||||
global _read_notification_ids
|
||||
snapshot_notifications = snapshot.get("notifications")
|
||||
if not isinstance(snapshot_notifications, list):
|
||||
return snapshot
|
||||
returned_ids = {
|
||||
notification.get("id")
|
||||
for notification in snapshot_notifications
|
||||
if isinstance(notification, dict)
|
||||
}
|
||||
updated = dict(snapshot)
|
||||
updated["notifications"] = [
|
||||
notification
|
||||
for notification in snapshot_notifications
|
||||
if not isinstance(notification, dict)
|
||||
or notification.get("id") not in _read_notification_ids
|
||||
]
|
||||
pr_models = [
|
||||
PullRequest(id=p["id"], number=p["number"], title=p["title"], state=p["state"], user=p.get("user", {}).get("login", ""), url=p["html_url"])
|
||||
for p in prs_data[:50]
|
||||
if isinstance(p, dict)
|
||||
]
|
||||
ctx = compute(user_model, repo_models, issue_models, pr_models)
|
||||
return JSONResponse({
|
||||
"user": ctx.user.dict(),
|
||||
"repos": [r.dict() for r in ctx.repos],
|
||||
"issues": [i.dict() for i in ctx.issues],
|
||||
"pull_requests": [p.dict() for p in ctx.pull_requests],
|
||||
"view": ctx.view,
|
||||
"deltas": [d.dict() for d in ctx.deltas],
|
||||
})
|
||||
_read_notification_ids = _read_notification_ids.intersection(returned_ids)
|
||||
return updated
|
||||
|
||||
|
||||
@app.get("/api/v1/live")
|
||||
async def live_snapshot() -> JSONResponse:
|
||||
"""Return a freshness-bounded snapshot and share identical upstream loads."""
|
||||
global _live_snapshot_task, _live_snapshot_value, _live_snapshot_created_at
|
||||
now = time.monotonic()
|
||||
if (
|
||||
_live_snapshot_value is not None
|
||||
and _live_snapshot_retry_at is not None
|
||||
):
|
||||
if now < _live_snapshot_retry_at:
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(
|
||||
_live_snapshot_value, stale=True, revalidating=False
|
||||
)
|
||||
)
|
||||
_start_live_snapshot_refresh()
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(
|
||||
_live_snapshot_value, stale=True, revalidating=True
|
||||
)
|
||||
)
|
||||
if (
|
||||
_live_snapshot_value is not None
|
||||
and _live_snapshot_created_at is not None
|
||||
and now - _live_snapshot_created_at < LIVE_SNAPSHOT_FRESHNESS_SECONDS
|
||||
):
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(
|
||||
_live_snapshot_value, stale=False, revalidating=False
|
||||
)
|
||||
)
|
||||
task = _start_live_snapshot_refresh()
|
||||
if _live_snapshot_value is not None:
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(
|
||||
_live_snapshot_value, stale=True, revalidating=True
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await asyncio.shield(task)
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(result, stale=False, revalidating=False)
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": f"Gitea live snapshot timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"},
|
||||
status_code=503,
|
||||
headers={"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Gitea live snapshot is temporarily unavailable"},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@app.get("/api/v1/events")
|
||||
async def event_stream():
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
activity_events(), timeout=EVENT_STREAM_TIMEOUT_SECONDS
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": (
|
||||
"Gitea event stream request timed out after "
|
||||
f"{EVENT_STREAM_TIMEOUT_SECONDS:g}s"
|
||||
)
|
||||
},
|
||||
status_code=503,
|
||||
headers={
|
||||
"Retry-After": str(
|
||||
max(1, math.ceil(EVENT_STREAM_TIMEOUT_SECONDS))
|
||||
)
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Gitea event stream is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={
|
||||
"Retry-After": str(
|
||||
max(1, math.ceil(EVENT_STREAM_TIMEOUT_SECONDS))
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _mark_notification_read_result(thread_id: int) -> tuple[int, bool]:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
mark_notification_read(thread_id),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
return thread_id, False
|
||||
_remove_notification_from_live_snapshot(thread_id)
|
||||
return thread_id, True
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/read")
|
||||
async def read_notifications(batch: NotificationReadBatch) -> JSONResponse:
|
||||
thread_ids = list(dict.fromkeys(batch.ids))
|
||||
|
||||
semaphore = asyncio.Semaphore(BULK_NOTIFICATION_CONCURRENCY)
|
||||
|
||||
async def mark_within_limit(thread_id: int) -> tuple[int, bool]:
|
||||
async with semaphore:
|
||||
return await _mark_notification_read_result(thread_id)
|
||||
|
||||
tasks = [asyncio.create_task(mark_within_limit(thread_id)) for thread_id in thread_ids]
|
||||
done, pending = await asyncio.wait(
|
||||
tasks, timeout=BULK_NOTIFICATION_DEADLINE_SECONDS
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
succeeded_ids = {
|
||||
thread_id
|
||||
for task in done
|
||||
if not task.cancelled() and task.exception() is None
|
||||
for thread_id, succeeded in [task.result()]
|
||||
if succeeded
|
||||
}
|
||||
failed = [thread_id for thread_id in thread_ids if thread_id not in succeeded_ids]
|
||||
return JSONResponse(
|
||||
{
|
||||
"marked": [thread_id for thread_id in thread_ids if thread_id in succeeded_ids],
|
||||
"failed": failed,
|
||||
},
|
||||
headers={"Retry-After": "1"} if failed else None,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/notifications")
|
||||
async def notification_page(page: int = Query(default=1, ge=1)) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.notification_page(page),
|
||||
timeout=NOTIFICATION_PAGE_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Unread updates timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Unread updates are temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/notifications/{thread_id}")
|
||||
async def notification_thread_detail(
|
||||
thread_id: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
notification_detail(thread_id),
|
||||
timeout=NOTIFICATION_DETAIL_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Loading the update timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The update is temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/{thread_id}/read")
|
||||
async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
mark_notification_read(thread_id),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Marking the update read timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The update could not be marked read. Please retry."},
|
||||
status_code=503,
|
||||
)
|
||||
_remove_notification_from_live_snapshot(thread_id)
|
||||
return JSONResponse({"id": thread_id, "status": "read"})
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review")
|
||||
async def review_detail(owner: str, repo: str, number: int):
|
||||
async def load_requested_review():
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await is_requested_review(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Review request not found")
|
||||
return await pull_review_detail(repository, number)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
load_requested_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Pull request review details timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={
|
||||
"Retry-After": str(
|
||||
max(1, math.ceil(REVIEW_DETAIL_TIMEOUT_SECONDS))
|
||||
)
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Pull request review details are temporarily unavailable"},
|
||||
status_code=503,
|
||||
)
|
||||
+15
-8
@@ -24,6 +24,8 @@ class Issue(BaseModel):
|
||||
state: str
|
||||
labels: list[str] = []
|
||||
assignees: list[str] = []
|
||||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
url: str
|
||||
|
||||
|
||||
@@ -33,20 +35,25 @@ class PullRequest(BaseModel):
|
||||
title: str
|
||||
state: str
|
||||
user: str
|
||||
labels: list[str] = []
|
||||
assignees: list[str] = []
|
||||
work_reasons: list[str] = []
|
||||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
url: str
|
||||
|
||||
|
||||
class SuggestionDelta(BaseModel):
|
||||
panel: str
|
||||
action: str
|
||||
target: str = ""
|
||||
priority: str = "medium"
|
||||
|
||||
|
||||
class ViewContext(BaseModel):
|
||||
user: User
|
||||
repos: list[Repo] = []
|
||||
issues: list[Issue] = []
|
||||
pull_requests: list[PullRequest] = []
|
||||
view: str = "dashboard"
|
||||
deltas: list[dict] = []
|
||||
|
||||
|
||||
class SuggestionDelta(BaseModel):
|
||||
panel: str
|
||||
action: str
|
||||
target: str = ""
|
||||
priority: str = "medium"
|
||||
deltas: list[SuggestionDelta] = []
|
||||
@@ -147,6 +147,8 @@ class GiteaClient:
|
||||
return json.loads(raw) if raw else None
|
||||
except error.HTTPError as exc:
|
||||
detail = exc.read().decode(errors="replace")
|
||||
if exc.code == 401:
|
||||
detail += " (authentication rejected; refresh the cron GITEA_TOKEN)"
|
||||
raise RuntimeError(f"Gitea {method} {path} failed: HTTP {exc.code}: {detail}") from exc
|
||||
|
||||
@staticmethod
|
||||
|
||||
+4
-1
@@ -1,9 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
router = APIRouter()
|
||||
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard() -> str:
|
||||
return open("frontend/index.html").read()
|
||||
return DASHBOARD_FILE.read_text()
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def empty_events(user_data=None):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
monkeypatch.setattr(main, "activity_events", empty_events)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
for path in ("/api/v1/context", "/api/v1/events", "/api/v1/live"):
|
||||
response = await client.get(path)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
@@ -0,0 +1,22 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
DASHBOARD_HTML = Path(__file__).parent.parent / "frontend" / "index.html"
|
||||
|
||||
|
||||
def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
html = DASHBOARD_HTML.read_text()
|
||||
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
|
||||
|
||||
assert api_paths
|
||||
assert {
|
||||
urljoin("https://forge.alexanderwhitestone.com/dashboard/", path)
|
||||
for path in api_paths
|
||||
} == {
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/live",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
WORKFLOW = Path(".gitea/workflows/ci.yml")
|
||||
|
||||
|
||||
def test_ci_uses_gitea_compatible_artifact_action():
|
||||
text = WORKFLOW.read_text()
|
||||
assert "actions/upload-artifact@v4" not in text
|
||||
assert "actions/upload-artifact@v3" in text
|
||||
|
||||
|
||||
def test_ci_installs_declared_requirements_before_tests():
|
||||
text = WORKFLOW.read_text()
|
||||
install = text.index("pip install -r requirements.txt")
|
||||
tests = text.index("python3 -m pytest tests/ -q")
|
||||
assert install < tests
|
||||
@@ -0,0 +1,48 @@
|
||||
import json
|
||||
import subprocess
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
COMMANDS = FRONTEND / "commands.js"
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.sources = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "script":
|
||||
source = dict(attrs).get("src")
|
||||
if source:
|
||||
self.sources.append(source)
|
||||
|
||||
|
||||
def test_filtered_command_runs_the_matching_action():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
let action = '';
|
||||
const commands = [
|
||||
{{ name: 'Open whiteboard', run: () => {{ action = 'whiteboard'; }} }},
|
||||
{{ name: 'Refresh now', run: () => {{ action = 'refresh'; }} }},
|
||||
];
|
||||
const matches = filterCommands(commands, 'refresh');
|
||||
if (matches.length !== 1) throw new Error(`expected one match, got ${{matches.length}}`);
|
||||
matches[0].run();
|
||||
if (action !== 'refresh') throw new Error(`expected refresh, got ${{action}}`);
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_command_script_resolves_inside_dashboard_subpath():
|
||||
parser = ScriptSourceParser()
|
||||
parser.feed((FRONTEND / "index.html").read_text())
|
||||
command_source = next(source for source in parser.sources if source.endswith("commands.js"))
|
||||
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/", command_source
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
|
||||
@@ -0,0 +1,65 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_context_poller_is_single_flight_and_pauses_until_visible():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
let calls = 0;
|
||||
let hidden = false;
|
||||
let resolveRequest;
|
||||
const timers = [];
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: () => {{
|
||||
calls += 1;
|
||||
if (calls > 1) return Promise.resolve({{ repos: [] }});
|
||||
return new Promise((resolve) => {{ resolveRequest = resolve; }});
|
||||
}},
|
||||
onSnapshot: () => {{}},
|
||||
onError: () => {{}},
|
||||
isHidden: () => hidden,
|
||||
setTimer: (callback) => {{ timers.push(callback); return timers.length; }},
|
||||
clearTimer: () => {{}},
|
||||
intervalMs: 8000,
|
||||
}});
|
||||
|
||||
(async () => {{
|
||||
const first = poller.start();
|
||||
const joined = poller.refresh();
|
||||
const callsWhilePending = calls;
|
||||
resolveRequest({{ repos: [] }});
|
||||
await Promise.all([first, joined]);
|
||||
|
||||
hidden = true;
|
||||
poller.setVisible(false);
|
||||
const scheduled = timers.shift();
|
||||
if (scheduled) scheduled();
|
||||
await Promise.resolve();
|
||||
const callsWhileHidden = calls;
|
||||
|
||||
hidden = false;
|
||||
await poller.setVisible(true);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
callsWhilePending,
|
||||
callsWhileHidden,
|
||||
callsAfterResume: calls,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"callsWhilePending": 1,
|
||||
"callsWhileHidden": 1,
|
||||
"callsAfterResume": 2,
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_returns_fallback_when_gitea_exceeds_deadline(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def hanging_current_user():
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "CONTEXT_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main, "current_user", hanging_current_user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert b'"repos":[]' in response.body
|
||||
assert b'"error":"Gitea context request timed out after 0.01s"' in response.body
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_hides_upstream_exception_details(monkeypatch):
|
||||
async def failing_user():
|
||||
raise RuntimeError("connection refused by secret.internal.example")
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", failing_user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
body = bytes(response.body)
|
||||
context = json.loads(body)
|
||||
|
||||
assert context["error"] == "Gitea context is temporarily unavailable"
|
||||
assert b"secret.internal.example" not in body
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_nullable_issue_collections(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def issue_collection():
|
||||
return [{
|
||||
"id": 50,
|
||||
"number": 50,
|
||||
"title": "Nullable metadata",
|
||||
"state": "open",
|
||||
"labels": None,
|
||||
"assignees": None,
|
||||
"html_url": "https://forge.example/issues/50",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", issue_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
issue = json.loads(response.body)["issues"][0]
|
||||
|
||||
assert issue["labels"] == []
|
||||
assert issue["assignees"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_skips_malformed_nested_issue_metadata(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def issue_collection():
|
||||
return [{
|
||||
"id": 85,
|
||||
"number": 85,
|
||||
"title": "Malformed nested metadata",
|
||||
"state": "open",
|
||||
"labels": [None, {"name": "bug"}, "invalid"],
|
||||
"assignees": [None, {"login": "timmy"}, "invalid"],
|
||||
"html_url": "https://forge.example/issues/85",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", issue_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
issue = json.loads(response.body)["issues"][0]
|
||||
|
||||
assert issue["labels"] == ["bug"]
|
||||
assert issue["assignees"] == ["timmy"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_nullable_repository_description(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def repo_collection():
|
||||
return [{
|
||||
"id": 54,
|
||||
"name": "dashboard",
|
||||
"full_name": "stackchain/dashboard",
|
||||
"description": None,
|
||||
"html_url": "https://forge.example/stackchain/dashboard",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", repo_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
repository = json.loads(response.body)["repos"][0]
|
||||
|
||||
assert repository["description"] == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_nullable_pull_request_author(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def pull_request_collection():
|
||||
return [{
|
||||
"id": 56,
|
||||
"number": 56,
|
||||
"title": "Nullable author",
|
||||
"state": "open",
|
||||
"user": None,
|
||||
"html_url": "https://forge.example/pulls/56",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", pull_request_collection)
|
||||
|
||||
response = await main.context()
|
||||
pull_request = json.loads(response.body)["pull_requests"][0]
|
||||
|
||||
assert pull_request["user"] == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_malformed_pull_request_author(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def pull_request_collection():
|
||||
return [{
|
||||
"id": 87,
|
||||
"number": 87,
|
||||
"title": "Malformed author",
|
||||
"state": "open",
|
||||
"user": "unknown",
|
||||
"html_url": "https://forge.example/pulls/87",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", pull_request_collection)
|
||||
|
||||
response = await main.context()
|
||||
pull_request = json.loads(response.body)["pull_requests"][0]
|
||||
|
||||
assert pull_request["user"] == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_null_collection_payloads(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def null_collection():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", null_collection)
|
||||
monkeypatch.setattr(main, "issues", null_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", null_collection)
|
||||
|
||||
response = await main.context()
|
||||
context = json.loads(response.body)
|
||||
|
||||
assert context["repos"] == []
|
||||
assert context["issues"] == []
|
||||
assert context["pull_requests"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_returns_fallback_for_malformed_collection_payloads(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def malformed_collection():
|
||||
return {"message": "unexpected upstream shape"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", malformed_collection)
|
||||
monkeypatch.setattr(main, "issues", malformed_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", malformed_collection)
|
||||
|
||||
response = await main.context()
|
||||
context = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert context["repos"] == []
|
||||
assert context["issues"] == []
|
||||
assert context["pull_requests"] == []
|
||||
assert context["error"] == "Gitea collection response was not a list"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_nullable_user_profile_fields(monkeypatch):
|
||||
async def user():
|
||||
return {
|
||||
"id": 1,
|
||||
"login": "timmy",
|
||||
"full_name": None,
|
||||
"email": None,
|
||||
}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
context = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert context["user"]["full_name"] == ""
|
||||
assert context["user"]["email"] == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_returns_fallback_for_null_current_user_payload(monkeypatch):
|
||||
async def null_user():
|
||||
return None
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", null_user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
context = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert context["repos"] == []
|
||||
assert context["error"] == "Gitea current-user response was not an object"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_returns_fallback_for_incomplete_current_user_payload(monkeypatch):
|
||||
async def incomplete_user():
|
||||
return {"id": 1}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", incomplete_user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
context = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert context["repos"] == []
|
||||
assert context["error"] == "Gitea current-user response did not include id and login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_skips_malformed_repository_entries(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def repository_collection():
|
||||
return [
|
||||
{"id": 79, "name": "missing-required-fields"},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "dashboard",
|
||||
"full_name": "stackchain/dashboard",
|
||||
"description": "Operations dashboard",
|
||||
"html_url": "https://forge.example/stackchain/dashboard",
|
||||
},
|
||||
]
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", repository_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
repositories = json.loads(response.body)["repos"]
|
||||
|
||||
assert [repository["name"] for repository in repositories] == ["dashboard"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_skips_malformed_issue_entries(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def issue_collection():
|
||||
return [
|
||||
{"id": 81, "title": "missing-required-fields"},
|
||||
{
|
||||
"id": 1,
|
||||
"number": 81,
|
||||
"title": "Valid issue",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/dashboard/issues/81",
|
||||
},
|
||||
]
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", issue_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
issues = json.loads(response.body)["issues"]
|
||||
|
||||
assert [issue["title"] for issue in issues] == ["Valid issue"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_skips_malformed_pull_request_entries(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def pull_request_collection():
|
||||
return [
|
||||
{"id": 83, "title": "missing-required-fields"},
|
||||
{
|
||||
"id": 1,
|
||||
"number": 83,
|
||||
"title": "Valid pull request",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/stackchain-dashboard/pulls/83",
|
||||
},
|
||||
]
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", pull_request_collection)
|
||||
|
||||
response = await main.context()
|
||||
pull_requests = json.loads(response.body)["pull_requests"]
|
||||
|
||||
assert [pull_request["title"] for pull_request in pull_requests] == [
|
||||
"Valid pull request"
|
||||
]
|
||||
@@ -0,0 +1,223 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src import gitea_proxy
|
||||
|
||||
|
||||
DASHBOARD = Path("frontend/index.html")
|
||||
|
||||
|
||||
def test_dashboard_has_realtime_gitea_event_stream_widget():
|
||||
html = DASHBOARD.read_text()
|
||||
|
||||
assert "Gitea event stream" in html
|
||||
assert "id=\"gitea-events\"" in html
|
||||
assert "function paintEventStream" in html
|
||||
assert "fetch('api/v1/live'" in html
|
||||
assert "paintEventStream(snapshot.events)" in html
|
||||
assert "loadEventStream" not in html
|
||||
assert "event.actor?.login" in html
|
||||
assert "event.repo?.full_name" in html
|
||||
|
||||
|
||||
def test_event_stream_reports_when_activity_was_refreshed():
|
||||
html = DASHBOARD.read_text()
|
||||
|
||||
assert 'id="gitea-events-status"' in html
|
||||
assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html
|
||||
|
||||
|
||||
def test_event_stream_reports_refresh_failure_without_erasing_visible_events():
|
||||
html = DASHBOARD.read_text()
|
||||
|
||||
assert "setEventStreamStatus('Update failed · showing last activity')" in html
|
||||
assert "qs('#gitea-events').innerHTML = '<div class=\"muted\">Event stream unavailable.</div>'" not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_event_stream_returns_recent_authenticated_gitea_activity(monkeypatch):
|
||||
expected = [{"type": "create", "actor": {"login": "timmy"}}]
|
||||
|
||||
async def fake_activity_events():
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr(main, "activity_events", fake_activity_events)
|
||||
|
||||
response = await main.event_stream()
|
||||
|
||||
assert response == expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_event_stream_returns_retryable_response_when_gitea_exceeds_deadline(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def hanging_activity_events():
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(main, "EVENT_STREAM_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main, "activity_events", hanging_activity_events)
|
||||
|
||||
response = await main.event_stream()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert json.loads(response.body) == {
|
||||
"error": "Gitea event stream request timed out after 0.01s"
|
||||
}
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_event_stream_returns_retryable_response_when_gitea_request_fails(monkeypatch):
|
||||
async def failing_activity_events():
|
||||
raise ConnectionError("connection refused")
|
||||
|
||||
monkeypatch.setattr(main, "activity_events", failing_activity_events)
|
||||
|
||||
response = await main.event_stream()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["retry-after"] == "5"
|
||||
assert json.loads(response.body) == {
|
||||
"error": "Gitea event stream is temporarily unavailable"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_fetches_feed_for_authenticated_user(monkeypatch):
|
||||
paths = []
|
||||
|
||||
async def fake_current_user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def fake_fetch(path):
|
||||
paths.append(path)
|
||||
return [{
|
||||
"op_type": "push",
|
||||
"act_user": {"login": "timmy"},
|
||||
"repo": {"full_name": "stackchain/stackchain-dashboard"},
|
||||
"created": "2026-08-05T03:00:00Z",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
result = await gitea_proxy.activity_events()
|
||||
|
||||
assert result == [{
|
||||
"type": "push",
|
||||
"actor": {"login": "timmy"},
|
||||
"repo": {"full_name": "stackchain/stackchain-dashboard"},
|
||||
"created_at": "2026-08-05T03:00:00Z",
|
||||
}]
|
||||
assert paths == ["users/timmy/activities/feeds?limit=20"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_reuses_supplied_authenticated_user(monkeypatch):
|
||||
async def unexpected_current_user():
|
||||
raise AssertionError("current user must not be fetched twice")
|
||||
|
||||
async def fake_fetch(path):
|
||||
assert path == "users/timmy/activities/feeds?limit=20"
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", unexpected_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
assert await gitea_proxy.activity_events({"login": "timmy"}) == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_skips_malformed_feed_entries(monkeypatch):
|
||||
async def fake_current_user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def fake_fetch(path):
|
||||
return [
|
||||
None,
|
||||
{
|
||||
"op_type": "push",
|
||||
"act_user": {"login": "timmy"},
|
||||
"repo": {"full_name": "stackchain/stackchain-dashboard"},
|
||||
"created": "2026-08-06T08:00:00Z",
|
||||
},
|
||||
"invalid",
|
||||
]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
result = await gitea_proxy.activity_events()
|
||||
|
||||
assert result == [{
|
||||
"type": "push",
|
||||
"actor": {"login": "timmy"},
|
||||
"repo": {"full_name": "stackchain/stackchain-dashboard"},
|
||||
"created_at": "2026-08-06T08:00:00Z",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_normalizes_null_feed_payload(monkeypatch):
|
||||
async def fake_current_user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def fake_fetch(path):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
assert await gitea_proxy.activity_events() == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_event_stream_rejects_malformed_activity_feed_payload(monkeypatch):
|
||||
async def fake_current_user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def fake_fetch(path):
|
||||
return {"message": "unexpected upstream shape"}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
response = await main.event_stream()
|
||||
|
||||
assert getattr(response, "status_code", None) == 503
|
||||
assert json.loads(response.body) == {
|
||||
"error": "Gitea event stream is temporarily unavailable"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_normalizes_malformed_nested_metadata(monkeypatch):
|
||||
async def fake_current_user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def fake_fetch(path):
|
||||
return [{
|
||||
"op_type": None,
|
||||
"act_user": "unknown",
|
||||
"repo": None,
|
||||
"created": None,
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
assert await gitea_proxy.activity_events() == [{
|
||||
"type": "activity",
|
||||
"actor": {},
|
||||
"repo": {},
|
||||
"created_at": "",
|
||||
}]
|
||||
@@ -0,0 +1,202 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_page_preserves_upstream_total_without_loading_other_pages():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Total-Count": "125"},
|
||||
json=[{
|
||||
"id": 42,
|
||||
"unread": True,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Retry failed deploy",
|
||||
"type": "Issue",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
},
|
||||
}],
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.notification_page(page=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/notifications?status-types=unread&limit=50&page=2"
|
||||
]
|
||||
assert result["page"] == 2
|
||||
assert result["total"] == 125
|
||||
assert result["has_more"] is True
|
||||
assert [item["id"] for item in result["items"]] == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_notifications_are_normalized_for_mobile_handoff():
|
||||
result = gitea_proxy._normalize_notifications(
|
||||
[
|
||||
{
|
||||
"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",
|
||||
]
|
||||
)
|
||||
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():
|
||||
with pytest.raises(ValueError, match="notification response was not a list"):
|
||||
gitea_proxy._normalize_notifications({"message": "unexpected"})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_reader():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
if request.url.path.endswith("/notifications/threads/42"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Retry failed deploy",
|
||||
"type": "Issue",
|
||||
"state": "open",
|
||||
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
|
||||
"latest_comment_url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
})
|
||||
if request.url.path.endswith("/issues/7"):
|
||||
return httpx.Response(200, json={"body": "Deploy fails after **three** retries."})
|
||||
if request.url.path.endswith("/issues/comments/9"):
|
||||
return httpx.Response(200, json={
|
||||
"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",
|
||||
})
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.notification_detail(42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||
]
|
||||
assert result == {
|
||||
"id": 42,
|
||||
"repository": "stackchain/api",
|
||||
"title": "Retry failed deploy",
|
||||
"subject_type": "Issue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"subject_body": "Deploy fails after **three** retries.",
|
||||
"latest_comment": {
|
||||
"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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_never_follows_foreign_api_urls():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"title": "Suspicious update",
|
||||
"url": "https://evil.example/api/v1/user",
|
||||
"latest_comment_url": "https://evil.example/api/v1/admin/users",
|
||||
"html_url": "javascript:alert(1)",
|
||||
},
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.notification_detail(42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42"
|
||||
]
|
||||
assert result["url"] == ""
|
||||
assert result["subject_body"] == ""
|
||||
assert result["latest_comment"]["body"] == ""
|
||||
@@ -0,0 +1,81 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_transport_is_reused_across_requests_and_closed():
|
||||
client_ids = []
|
||||
|
||||
async def handler(request):
|
||||
client_ids.append(id(gitea_proxy._client))
|
||||
return httpx.Response(200, json={"path": request.url.path})
|
||||
|
||||
client = gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
first = await gitea_proxy.fetch("user")
|
||||
second = await gitea_proxy.fetch("user/repos")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert first == {"path": "/api/v1/user"}
|
||||
assert second == {"path": "/api/v1/user/repos"}
|
||||
assert client_ids == [id(client), id(client)]
|
||||
assert client.is_closed
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_application_lifespan_opens_and_closes_gitea_transport(monkeypatch):
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
|
||||
async def stop_client():
|
||||
calls.append("stop")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
|
||||
|
||||
async with main.app.router.lifespan_context(main.app):
|
||||
assert calls == ["start"]
|
||||
|
||||
assert calls == ["start", "stop"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_application_shutdown_finishes_snapshot_before_closing_transport(monkeypatch):
|
||||
calls = []
|
||||
started = asyncio.Event()
|
||||
|
||||
async def active_snapshot():
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
calls.append("snapshot cancelled")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
|
||||
|
||||
async def stop_client():
|
||||
assert main._live_snapshot_task is not None
|
||||
state = "done" if main._live_snapshot_task.done() else "active"
|
||||
calls.append(f"stop ({state})")
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
|
||||
|
||||
try:
|
||||
async with main.app.router.lifespan_context(main.app):
|
||||
main._live_snapshot_task = asyncio.create_task(active_snapshot())
|
||||
await started.wait()
|
||||
finally:
|
||||
task = main._live_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
main._live_snapshot_task = None
|
||||
|
||||
assert calls == ["start", "snapshot cancelled", "stop (done)"]
|
||||
@@ -0,0 +1,305 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_collections_include_supported_review_request_search(monkeypatch):
|
||||
requested_paths = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested_paths.append(path)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
assert await gitea_proxy.issues() == []
|
||||
assert await gitea_proxy.pull_requests() == []
|
||||
assert requested_paths == [
|
||||
"repos/issues/search?state=open&assigned=true&type=issues&limit=50",
|
||||
"repos/issues/search?state=open&assigned=true&type=pulls&limit=50",
|
||||
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_requests_merge_assignment_and_review_responsibilities(monkeypatch):
|
||||
assigned = {
|
||||
"id": 11,
|
||||
"number": 7,
|
||||
"title": "Review API",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
}
|
||||
review_only = {
|
||||
"id": 12,
|
||||
"number": 8,
|
||||
"title": "Review mobile",
|
||||
"repository": {"full_name": "stackchain/mobile"},
|
||||
}
|
||||
|
||||
async def fake_fetch(path):
|
||||
if "assigned=true" in path:
|
||||
return [assigned]
|
||||
return [assigned.copy(), review_only]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
assert [pull["id"] for pull in pulls] == [11, 12]
|
||||
assert pulls[0]["work_reasons"] == ["assigned_to_me", "review_requested"]
|
||||
assert pulls[1]["work_reasons"] == ["review_requested"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_request_searches_start_concurrently(monkeypatch):
|
||||
started = [asyncio.Event(), asyncio.Event()]
|
||||
release = asyncio.Event()
|
||||
|
||||
async def fake_fetch(path):
|
||||
index = 0 if "assigned=true" in path else 1
|
||||
started[index].set()
|
||||
await release.wait()
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
task = asyncio.create_task(gitea_proxy.pull_requests())
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*(event.wait() for event in started)), timeout=1
|
||||
)
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
assert await task == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_requested_review_guard_uses_only_dedicated_review_search(monkeypatch):
|
||||
requested_paths = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested_paths.append(path)
|
||||
return [
|
||||
{
|
||||
"number": 7,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
},
|
||||
{
|
||||
"number": 8,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
assert await gitea_proxy.is_requested_review("stackchain/api", 7) is True
|
||||
assert await gitea_proxy.is_requested_review("stackchain/api", 9) is False
|
||||
assert await gitea_proxy.is_requested_review("stackchain/private", 7) is False
|
||||
assert requested_paths == [
|
||||
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
|
||||
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
|
||||
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_preserves_repository_and_update_time_for_cross_repo_work(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def repositories():
|
||||
return []
|
||||
|
||||
async def issues():
|
||||
return [{
|
||||
"id": 10,
|
||||
"number": 7,
|
||||
"title": "Ship mobile flow",
|
||||
"state": "open",
|
||||
"labels": [{"name": "P0"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"repository": {"full_name": "stackchain/mobile"},
|
||||
"updated_at": "2026-08-06T12:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/mobile/issues/7",
|
||||
}]
|
||||
|
||||
async def pulls():
|
||||
return [{
|
||||
"id": 11,
|
||||
"number": 7,
|
||||
"title": "Review API",
|
||||
"state": "open",
|
||||
"user": {"login": "alex"},
|
||||
"labels": [{"name": "priority-high"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"work_reasons": ["assigned_to_me", "review_requested"],
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"updated_at": "2026-08-06T11:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/pulls/7",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", repositories)
|
||||
monkeypatch.setattr(main, "issues", issues)
|
||||
monkeypatch.setattr(main, "pull_requests", pulls)
|
||||
|
||||
payload = json.loads((await main.context()).body)
|
||||
|
||||
assert payload["issues"][0]["repository"] == "stackchain/mobile"
|
||||
assert payload["issues"][0]["updated_at"] == "2026-08-06T12:00:00Z"
|
||||
assert payload["pull_requests"][0]["repository"] == "stackchain/api"
|
||||
assert payload["pull_requests"][0]["updated_at"] == "2026-08-06T11:00:00Z"
|
||||
assert payload["pull_requests"][0]["labels"] == ["priority-high"]
|
||||
assert payload["pull_requests"][0]["assignees"] == ["timmy"]
|
||||
assert payload["pull_requests"][0]["work_reasons"] == [
|
||||
"assigned_to_me",
|
||||
"review_requested",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_review_detail_combines_pr_files_status_and_reviews(monkeypatch):
|
||||
requested_paths = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested_paths.append(path)
|
||||
if path.endswith("/pulls/7"):
|
||||
return {
|
||||
"title": "Review API",
|
||||
"body": "Please check the retry flow.",
|
||||
"html_url": "https://forge.example/stackchain/api/pulls/7",
|
||||
"user": {"login": "alex"},
|
||||
"head": {"sha": "abc123"},
|
||||
}
|
||||
if path.endswith("/files"):
|
||||
return [
|
||||
{"filename": "src/api.py", "status": "modified", "additions": 8, "deletions": 2},
|
||||
None,
|
||||
]
|
||||
if "/commits/abc123/status" in path:
|
||||
return {"state": "success"}
|
||||
return [
|
||||
{"user": {"login": "sam"}, "state": "APPROVED", "body": "Looks good"},
|
||||
"malformed",
|
||||
]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
async def fake_fetch_text(path, max_bytes):
|
||||
assert path == "repos/stackchain/api/pulls/7.diff"
|
||||
assert max_bytes == gitea_proxy.REVIEW_DIFF_MAX_BYTES
|
||||
return "", False
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch_text", fake_fetch_text)
|
||||
|
||||
detail = await gitea_proxy.pull_review_detail("stackchain/api", 7)
|
||||
|
||||
assert requested_paths == [
|
||||
"repos/stackchain/api/pulls/7",
|
||||
"repos/stackchain/api/pulls/7/files",
|
||||
"repos/stackchain/api/commits/abc123/status",
|
||||
"repos/stackchain/api/pulls/7/reviews",
|
||||
]
|
||||
assert detail["author"] == "alex"
|
||||
assert detail["head_sha"] == "abc123"
|
||||
assert detail["ci_state"] == "success"
|
||||
assert detail["files"][0]["filename"] == "src/api.py"
|
||||
assert detail["reviews"][0]["state"] == "APPROVED"
|
||||
assert len(detail["files"]) == 1
|
||||
assert len(detail["reviews"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_review_detail_fetches_independent_head_resources_concurrently(monkeypatch):
|
||||
started = {name: asyncio.Event() for name in ("files", "status", "reviews", "diff")}
|
||||
release = asyncio.Event()
|
||||
|
||||
async def wait_for_release(name, result):
|
||||
started[name].set()
|
||||
await release.wait()
|
||||
return result
|
||||
|
||||
async def fake_fetch(path):
|
||||
if path.endswith("/pulls/7"):
|
||||
return {"head": {"sha": "abc123"}, "user": {"login": "alex"}}
|
||||
if path.endswith("/files"):
|
||||
return await wait_for_release("files", [])
|
||||
if path.endswith("/reviews"):
|
||||
return await wait_for_release("reviews", [])
|
||||
return await wait_for_release("status", {"state": "success"})
|
||||
|
||||
async def fake_fetch_text(path, max_bytes):
|
||||
return await wait_for_release("diff", ("", False))
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch_text", fake_fetch_text)
|
||||
|
||||
detail_task = asyncio.create_task(
|
||||
gitea_proxy.pull_review_detail("stackchain/api", 7)
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*(event.wait() for event in started.values())), timeout=1
|
||||
)
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
detail = await detail_task
|
||||
assert detail["head_sha"] == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_review_detail_attaches_bounded_per_file_diff_previews(monkeypatch):
|
||||
async def fake_fetch(path):
|
||||
if path.endswith("/pulls/7"):
|
||||
return {
|
||||
"title": "Review API",
|
||||
"head": {"sha": "abc123"},
|
||||
"user": {"login": "alex"},
|
||||
}
|
||||
if path.endswith("/files"):
|
||||
return [
|
||||
{"filename": "src/api.py", "status": "modified"},
|
||||
{"filename": "assets/logo.png", "status": "modified"},
|
||||
]
|
||||
if path.endswith("/reviews"):
|
||||
return []
|
||||
return {"state": "success"}
|
||||
|
||||
diff = """diff --git a/src/api.py b/src/api.py
|
||||
index 123..456 100644
|
||||
--- a/src/api.py
|
||||
+++ b/src/api.py
|
||||
@@ -1,2 +1,3 @@
|
||||
context
|
||||
-old <value>
|
||||
+new <value>
|
||||
diff --git a/assets/logo.png b/assets/logo.png
|
||||
Binary files a/assets/logo.png and b/assets/logo.png differ
|
||||
"""
|
||||
|
||||
async def fake_fetch_text(path, max_bytes):
|
||||
assert path == "repos/stackchain/api/pulls/7.diff"
|
||||
return diff, True
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch_text", fake_fetch_text)
|
||||
|
||||
detail = await gitea_proxy.pull_review_detail("stackchain/api", 7)
|
||||
|
||||
source, binary = detail["files"]
|
||||
assert source["diff_lines"] == [
|
||||
"@@ -1,2 +1,3 @@",
|
||||
" context",
|
||||
"-old <value>",
|
||||
"+new <value>",
|
||||
]
|
||||
assert source["diff_truncated"] is True
|
||||
assert source["diff_available"] is True
|
||||
assert binary["diff_available"] is False
|
||||
assert binary["diff_binary"] is True
|
||||
@@ -0,0 +1,89 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
def test_health_endpoint_reports_service_liveness_without_gitea_access():
|
||||
assert any(getattr(route, "path", None) == "/healthz" for route in main.app.routes)
|
||||
assert main.health() == {"status": "ok", "service": "stackchain-dashboard"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
|
||||
async def connected_user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", connected_user)
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert any(getattr(route, "path", None) == "/readyz" for route in main.app.routes)
|
||||
assert response == {
|
||||
"status": "ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"gitea_user": "timmy",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_hides_unexpected_upstream_error_details(monkeypatch):
|
||||
async def unavailable_user():
|
||||
raise RuntimeError("secret internal upstream detail")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", unavailable_user)
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert json.loads(response.body) == {
|
||||
"status": "not_ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"error": "Gitea readiness check is temporarily unavailable",
|
||||
}
|
||||
assert b"secret internal upstream detail" not in response.body
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_returns_503_for_null_current_user_payload(monkeypatch):
|
||||
async def null_user():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(main, "current_user", null_user)
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert json.loads(response.body) == {
|
||||
"status": "not_ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"error": "Gitea current-user response did not include a login",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def hanging_user():
|
||||
try:
|
||||
await asyncio.sleep(0.05)
|
||||
return {"login": "too-late"}
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(main, "READINESS_TIMEOUT_SECONDS", 0.01, raising=False)
|
||||
monkeypatch.setattr(main, "current_user", hanging_user)
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert json.loads(response.body) == {
|
||||
"status": "not_ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"error": "Gitea readiness check timed out after 0.01s",
|
||||
}
|
||||
assert cancelled.is_set()
|
||||
@@ -0,0 +1,468 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_live_snapshot_task():
|
||||
main._live_snapshot_task = None
|
||||
main._live_snapshot_value = None
|
||||
main._live_snapshot_created_at = None
|
||||
main._live_snapshot_failure_count = 0
|
||||
main._live_snapshot_retry_at = None
|
||||
yield
|
||||
main._live_snapshot_task = None
|
||||
main._live_snapshot_value = None
|
||||
main._live_snapshot_created_at = None
|
||||
main._live_snapshot_failure_count = 0
|
||||
main._live_snapshot_retry_at = None
|
||||
|
||||
|
||||
def payload(response):
|
||||
return json.loads(response.body)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(monkeypatch):
|
||||
calls = {"user": 0}
|
||||
|
||||
async def user():
|
||||
calls["user"] += 1
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(authenticated_user):
|
||||
assert authenticated_user["login"] == "timmy"
|
||||
return [{"type": "push"}]
|
||||
|
||||
async def updates():
|
||||
return {
|
||||
"items": [{"id": 42, "title": "Mentioned you"}],
|
||||
"page": 1,
|
||||
"total": 125,
|
||||
"has_more": True,
|
||||
}
|
||||
|
||||
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", updates)
|
||||
|
||||
response = await main.live_snapshot()
|
||||
result = payload(response)
|
||||
|
||||
assert calls["user"] == 1
|
||||
assert result["context"]["user"]["login"] == "timmy"
|
||||
assert result["events"] == [{"type": "push"}]
|
||||
assert result["notifications"] == [{"id": 42, "title": "Mentioned you"}]
|
||||
assert result["notification_pagination"] == {
|
||||
"page": 1, "total": 125, "has_more": True
|
||||
}
|
||||
assert result["sections"] == {
|
||||
"context": "fresh", "events": "fresh", "notifications": "fresh"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_keeps_fresh_context_when_activity_fails(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def failing_events(authenticated_user):
|
||||
raise ConnectionError("secret upstream detail")
|
||||
|
||||
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", failing_events)
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
result = payload(await main.live_snapshot())
|
||||
|
||||
assert result["context"]["user"]["login"] == "timmy"
|
||||
assert result["events"] is None
|
||||
assert result["sections"] == {
|
||||
"context": "fresh",
|
||||
"events": "temporarily unavailable",
|
||||
"notifications": "fresh",
|
||||
}
|
||||
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
|
||||
async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def failing_repos():
|
||||
raise ConnectionError("work unavailable")
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(authenticated_user):
|
||||
return [{"type": "push"}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", failing_repos)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", events)
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
result = payload(await main.live_snapshot())
|
||||
|
||||
assert result["context"] is None
|
||||
assert result["events"] == [{"type": "push"}]
|
||||
assert result["sections"] == {
|
||||
"context": "temporarily unavailable",
|
||||
"events": "fresh",
|
||||
"notifications": "fresh",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_reuses_completed_snapshot_inside_freshness_window(monkeypatch):
|
||||
user_calls = 0
|
||||
release = asyncio.Event()
|
||||
|
||||
async def user():
|
||||
nonlocal user_calls
|
||||
user_calls += 1
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def blocked_repos():
|
||||
await release.wait()
|
||||
return []
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(authenticated_user):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", blocked_repos)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", events)
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
first = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
second = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
assert user_calls == 1
|
||||
|
||||
release.set()
|
||||
await asyncio.gather(first, second)
|
||||
await main.live_snapshot()
|
||||
|
||||
assert user_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_snapshot_returns_immediately_while_one_refresh_revalidates(monkeypatch):
|
||||
now = 100.0
|
||||
refresh_started = asyncio.Event()
|
||||
release_refresh = asyncio.Event()
|
||||
builds = 0
|
||||
|
||||
async def snapshot():
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds == 2:
|
||||
refresh_started.set()
|
||||
await release_refresh.wait()
|
||||
return {
|
||||
"context": {"generation": builds},
|
||||
"events": [],
|
||||
"notifications": [],
|
||||
"sections": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||
|
||||
first = payload(await main.live_snapshot())
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
stale_request = asyncio.create_task(main.live_snapshot())
|
||||
await refresh_started.wait()
|
||||
await asyncio.sleep(0)
|
||||
returned_immediately = stale_request.done()
|
||||
release_refresh.set()
|
||||
stale = payload(await stale_request)
|
||||
|
||||
assert returned_immediately is True
|
||||
assert first["context"]["generation"] == 1
|
||||
assert stale["context"]["generation"] == 1
|
||||
assert stale["freshness"]["stale"] is True
|
||||
assert stale["freshness"]["revalidating"] is True
|
||||
assert builds == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_revalidation_enters_cooldown_and_keeps_last_snapshot(monkeypatch):
|
||||
now = 100.0
|
||||
builds = 0
|
||||
|
||||
async def snapshot():
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds > 1:
|
||||
raise ConnectionError("private upstream failure")
|
||||
return {
|
||||
"context": {"generation": 1},
|
||||
"events": [{"id": 1}],
|
||||
"notifications": [],
|
||||
"sections": {
|
||||
"context": "fresh",
|
||||
"events": "fresh",
|
||||
"notifications": "fresh",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||
|
||||
await main.live_snapshot()
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
stale = payload(await main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
degraded = payload(await main.live_snapshot())
|
||||
|
||||
assert stale["freshness"]["revalidating"] is True
|
||||
assert degraded["context"] == {"generation": 1}
|
||||
assert degraded["freshness"]["stale"] is True
|
||||
assert degraded["freshness"]["degraded"] is True
|
||||
assert degraded["freshness"]["last_refresh_failed"] is True
|
||||
assert degraded["freshness"]["revalidating"] is False
|
||||
assert degraded["freshness"]["retry_in_seconds"] > 0
|
||||
assert builds == 2
|
||||
assert "private" not in json.dumps(degraded)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_partial_refresh_updates_fresh_sections_and_retains_failed_sections(monkeypatch):
|
||||
now = 100.0
|
||||
builds = 0
|
||||
|
||||
async def snapshot():
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds == 1:
|
||||
return {
|
||||
"context": {"generation": 1},
|
||||
"events": [{"id": "last-known"}],
|
||||
"notifications": [{"id": 7}],
|
||||
"notification_pagination": {"page": 1, "total": 1, "has_more": False},
|
||||
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
|
||||
}
|
||||
return {
|
||||
"context": {"generation": 2},
|
||||
"events": None,
|
||||
"notifications": [{"id": 8}],
|
||||
"notification_pagination": {"page": 1, "total": 1, "has_more": False},
|
||||
"sections": {
|
||||
"context": "fresh",
|
||||
"events": "temporarily unavailable",
|
||||
"notifications": "fresh",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||
|
||||
await main.live_snapshot()
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
await main.live_snapshot()
|
||||
await asyncio.sleep(0)
|
||||
result = payload(await main.live_snapshot())
|
||||
|
||||
assert result["context"] == {"generation": 2}
|
||||
assert result["events"] == [{"id": "last-known"}]
|
||||
assert result["notifications"] == [{"id": 8}]
|
||||
assert result["sections"] == {
|
||||
"context": "fresh",
|
||||
"events": "stale",
|
||||
"notifications": "fresh",
|
||||
}
|
||||
assert result["freshness"]["degraded"] is True
|
||||
assert result["freshness"]["revalidating"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state(monkeypatch):
|
||||
now = 100.0
|
||||
builds = 0
|
||||
retry_started = asyncio.Event()
|
||||
release_retry = asyncio.Event()
|
||||
|
||||
async def snapshot():
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds == 1:
|
||||
return {
|
||||
"context": {"generation": 1}, "events": [], "notifications": [],
|
||||
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
|
||||
}
|
||||
if builds == 2:
|
||||
return {
|
||||
"context": None, "events": [], "notifications": [],
|
||||
"sections": {
|
||||
"context": "temporarily unavailable",
|
||||
"events": "fresh",
|
||||
"notifications": "fresh",
|
||||
},
|
||||
}
|
||||
retry_started.set()
|
||||
await release_retry.wait()
|
||||
return {
|
||||
"context": {"generation": 3}, "events": [], "notifications": [],
|
||||
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
|
||||
|
||||
await main.live_snapshot()
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
await main.live_snapshot()
|
||||
await asyncio.sleep(0)
|
||||
now += main.LIVE_SNAPSHOT_RETRY_BASE_SECONDS
|
||||
|
||||
first = asyncio.create_task(main.live_snapshot())
|
||||
second = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
assert retry_started.is_set()
|
||||
assert builds == 3
|
||||
assert payload(await first)["freshness"]["revalidating"] is True
|
||||
assert payload(await second)["freshness"]["revalidating"] is True
|
||||
|
||||
release_retry.set()
|
||||
refresh_task = main._live_snapshot_task
|
||||
assert refresh_task is not None
|
||||
await refresh_task
|
||||
recovered = payload(await main.live_snapshot())
|
||||
|
||||
assert recovered["context"] == {"generation": 3}
|
||||
assert recovered["freshness"]["degraded"] is False
|
||||
assert recovered["freshness"]["last_refresh_failed"] is False
|
||||
assert recovered["freshness"]["retry_in_seconds"] == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cancelling_one_waiter_does_not_cancel_the_shared_snapshot(monkeypatch):
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
snapshot_calls = 0
|
||||
|
||||
async def blocked_snapshot():
|
||||
nonlocal snapshot_calls
|
||||
snapshot_calls += 1
|
||||
started.set()
|
||||
await release.wait()
|
||||
return {
|
||||
"context": {},
|
||||
"events": [],
|
||||
"notifications": [],
|
||||
"sections": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", blocked_snapshot)
|
||||
|
||||
disconnected = asyncio.create_task(main.live_snapshot())
|
||||
await started.wait()
|
||||
survivor = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
disconnected.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await disconnected
|
||||
|
||||
assert main._live_snapshot_task is not None
|
||||
assert not main._live_snapshot_task.done()
|
||||
|
||||
release.set()
|
||||
response = await survivor
|
||||
|
||||
assert response.status_code == 200
|
||||
assert snapshot_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shared_snapshot_deadline_cancels_upstream_work_for_all_waiters(monkeypatch):
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def snapshot_that_exceeds_deadline():
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(main, "_build_live_snapshot", snapshot_that_exceeds_deadline)
|
||||
monkeypatch.setattr(main, "CONTEXT_TIMEOUT_SECONDS", 0.01)
|
||||
|
||||
first = asyncio.create_task(main.live_snapshot())
|
||||
await started.wait()
|
||||
second = asyncio.create_task(main.live_snapshot())
|
||||
|
||||
try:
|
||||
responses = await asyncio.gather(first, second)
|
||||
await asyncio.wait_for(cancelled.wait(), timeout=0.1)
|
||||
|
||||
assert [response.status_code for response in responses] == [503, 503]
|
||||
assert [payload(response)["error"] for response in responses] == [
|
||||
"Gitea live snapshot timed out after 0.01s",
|
||||
"Gitea live snapshot timed out after 0.01s",
|
||||
]
|
||||
finally:
|
||||
task = main._live_snapshot_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
import subprocess
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parent.parent / "frontend"
|
||||
RENDERER = FRONTEND / "markdown.js"
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.sources = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "script":
|
||||
source = dict(attrs).get("src")
|
||||
if source:
|
||||
self.sources.append(source)
|
||||
|
||||
|
||||
def test_markdown_renderer_escapes_raw_html_before_rendering_heading():
|
||||
payload = "# <img src=x onerror=alert(document.domain)>"
|
||||
script = (
|
||||
f"const render = require({json.dumps(str(RENDERER))});"
|
||||
f"process.stdout.write(render({json.dumps(payload)}));"
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.stdout == (
|
||||
"<h1><img src=x onerror=alert(document.domain)></h1>"
|
||||
)
|
||||
|
||||
|
||||
def test_markdown_script_resolves_inside_dashboard_subpath():
|
||||
parser = ScriptSourceParser()
|
||||
parser.feed((FRONTEND / "index.html").read_text())
|
||||
markdown_source = next(
|
||||
source for source in parser.sources if source.endswith("markdown.js")
|
||||
)
|
||||
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/", markdown_source
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/static/markdown.js"
|
||||
@@ -0,0 +1,943 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
||||
|
||||
|
||||
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
"issues": [
|
||||
{
|
||||
"id": 1,
|
||||
"number": 7,
|
||||
"title": "Assigned issue",
|
||||
"state": "open",
|
||||
"repository": "stackchain/mobile",
|
||||
"labels": [],
|
||||
"assignees": ["timmy"],
|
||||
"updated_at": "2026-08-06T12:00:00Z",
|
||||
"url": "https://forge.example/mobile/issues/7",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"number": 7,
|
||||
"title": "Priority issue",
|
||||
"state": "open",
|
||||
"repository": "stackchain/api",
|
||||
"labels": ["P0"],
|
||||
"assignees": [],
|
||||
"updated_at": "2026-08-06T11:00:00Z",
|
||||
"url": "https://forge.example/api/issues/7",
|
||||
},
|
||||
],
|
||||
"pull_requests": [
|
||||
{
|
||||
"id": 3,
|
||||
"number": 4,
|
||||
"title": "Review PR",
|
||||
"state": "open",
|
||||
"repository": "stackchain/web",
|
||||
"work_reasons": ["review_requested"],
|
||||
"updated_at": "2026-08-06T13:00:00Z",
|
||||
"url": "https://forge.example/web/pulls/4",
|
||||
}
|
||||
],
|
||||
}
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const queue = buildMyWork({json.dumps(payload)});
|
||||
process.stdout.write(JSON.stringify(queue));
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
queue = json.loads(result.stdout)
|
||||
|
||||
assert [item["title"] for item in queue] == [
|
||||
"Priority issue",
|
||||
"Review PR",
|
||||
"Assigned issue",
|
||||
]
|
||||
assert queue[0]["key"] == "stackchain/api#7"
|
||||
assert queue[0]["reason"] == "P0 priority"
|
||||
assert queue[1]["reason"] == "Needs your review"
|
||||
assert queue[1]["is_review"] is True
|
||||
assert queue[2]["reason"] == "Assigned to you"
|
||||
|
||||
|
||||
def test_my_work_reviews_filter_and_summary_are_actionable():
|
||||
items = [
|
||||
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
|
||||
{"title": "Assigned PR", "kind": "pull", "is_review": False, "is_assigned": True},
|
||||
{"title": "Review PR", "kind": "pull", "is_review": True, "is_assigned": False},
|
||||
]
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = {json.dumps(items)};
|
||||
process.stdout.write(JSON.stringify({{
|
||||
reviews: buildMyWork.filterMyWork(items, 'review'),
|
||||
summary: buildMyWork.summarizeMyWork(items),
|
||||
}}));
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert [item["title"] for item in output["reviews"]] == ["Review PR"]
|
||||
assert output["summary"] == "1 review · 2 assigned"
|
||||
|
||||
|
||||
def test_my_work_filter_counts_distinguish_prs_from_review_requests():
|
||||
items = [
|
||||
{"kind": "issue", "is_review": False},
|
||||
{"kind": "pull", "is_review": False},
|
||||
{"kind": "pull", "is_review": True},
|
||||
]
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})));
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["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}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_notification_identity_survives_merge_and_acknowledgement_preserves_assigned_work():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
"issues": [{
|
||||
"number": 7, "title": "Assigned issue", "repository": "stackchain/api",
|
||||
"assignees": ["timmy"], "url": "https://forge.example/issues/7",
|
||||
}],
|
||||
"notifications": [
|
||||
{"id": 42, "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
|
||||
"subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/7#comment"},
|
||||
{"id": 43, "number": 8, "title": "Mention", "repository": "stackchain/web",
|
||||
"subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/8"},
|
||||
],
|
||||
}
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const before = buildMyWork({json.dumps(payload)});
|
||||
const afterMerged = buildMyWork.acknowledgeNotification(before, 42);
|
||||
const afterStandalone = buildMyWork.acknowledgeNotification(before, 43);
|
||||
process.stdout.write(JSON.stringify({{before, afterMerged, afterStandalone}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assigned = next(item for item in output["before"] if item["kind"] == "issue")
|
||||
assert assigned["notification_id"] == 42
|
||||
assert assigned["has_update"] is True
|
||||
assert len(output["afterMerged"]) == 2
|
||||
acknowledged = next(item for item in output["afterMerged"] if item["kind"] == "issue")
|
||||
assert acknowledged["has_update"] is False
|
||||
assert "notification_id" not in acknowledged
|
||||
assert [item["notification_id"] for item in output["afterStandalone"] if item["has_update"]] == [42]
|
||||
|
||||
|
||||
def test_notification_acknowledger_is_single_flight_and_rolls_back_on_failure():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const original = [{{kind:'update', notification_id:42, has_update:true}}];
|
||||
let calls = 0;
|
||||
let release;
|
||||
const states = [];
|
||||
const statuses = [];
|
||||
const controller = buildMyWork.createNotificationAcknowledger({{
|
||||
markRead: () => {{ calls += 1; return new Promise((resolve, reject) => {{ release = reject; }}); }},
|
||||
onItems: items => states.push(items),
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
const first = controller.acknowledge(original, 42);
|
||||
const duplicate = controller.acknowledge(original, 42);
|
||||
release(new Error('offline'));
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, states, statuses, results
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == 1
|
||||
assert output["states"] == [[], [{"kind": "update", "notification_id": 42, "has_update": True}]]
|
||||
assert output["statuses"] == ["Marking update read…", "Could not mark update read. Retry."]
|
||||
assert output["results"] == [False, False]
|
||||
|
||||
|
||||
def test_bulk_notification_acknowledger_deduplicates_and_keeps_partial_failures_retryable():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const original = [
|
||||
{{kind:'issue', key:'repo#1', notification_id:42, has_update:true}},
|
||||
{{kind:'update', key:'repo#2', notification_id:43, has_update:true}},
|
||||
{{kind:'update', key:'repo#3', notification_id:44, has_update:true}},
|
||||
];
|
||||
let calls = 0;
|
||||
let release;
|
||||
const states = [];
|
||||
const statuses = [];
|
||||
const controller = buildMyWork.createBulkNotificationAcknowledger({{
|
||||
markRead: ids => {{
|
||||
calls += 1;
|
||||
return new Promise(resolve => {{ release = () => resolve({{marked:[42,43], failed:[44]}}); }});
|
||||
}},
|
||||
onItems: items => states.push(items),
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
const first = controller.acknowledge(original, [42, 43, 42, 44]);
|
||||
const duplicate = controller.acknowledge(original, [42, 43, 44]);
|
||||
release();
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, states, statuses, results,
|
||||
retryIds: buildMyWork.notificationIds(states.at(-1)),
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == 1
|
||||
assert output["results"] == [
|
||||
{"marked": [42, 43], "failed": [44]},
|
||||
False,
|
||||
]
|
||||
assert output["states"] == [[
|
||||
{"kind": "issue", "key": "repo#1", "has_update": False},
|
||||
{"kind": "update", "key": "repo#3", "notification_id": 44, "has_update": True},
|
||||
]]
|
||||
assert output["retryIds"] == [44]
|
||||
assert output["statuses"] == [
|
||||
"Marking 3 updates read…",
|
||||
"2 marked read · 1 could not be updated — retry.",
|
||||
]
|
||||
|
||||
|
||||
def test_notification_pager_is_single_flight_and_merges_unique_updates():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let calls = 0;
|
||||
let release;
|
||||
const pages = [];
|
||||
const notifications = [];
|
||||
const statuses = [];
|
||||
const pager = buildMyWork.createNotificationPager({{
|
||||
load: page => {{
|
||||
calls += 1;
|
||||
return new Promise(resolve => {{ release = () => resolve({{
|
||||
items: [{{id:50, title:'duplicate'}}, {{id:51, title:'older'}}],
|
||||
page, total: 75, has_more: false,
|
||||
}}); }});
|
||||
}},
|
||||
onNotifications: items => notifications.push(items),
|
||||
onPagination: page => pages.push(page),
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
pager.reset({{page:1, total:75, has_more:true}});
|
||||
const existing = [{{id:50, title:'newer'}}];
|
||||
const first = pager.loadMore(existing);
|
||||
const duplicate = pager.loadMore(existing);
|
||||
release();
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, pages, notifications, statuses, results
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == 1
|
||||
assert output["notifications"] == [[
|
||||
{"id": 50, "title": "newer"},
|
||||
{"id": 51, "title": "older"},
|
||||
]]
|
||||
assert output["pages"][-1] == {"page": 2, "total": 75, "has_more": False}
|
||||
assert output["statuses"] == ["Loading older updates…", "75 of 75 unread updates loaded."]
|
||||
assert output["results"] == [True, False]
|
||||
|
||||
|
||||
def test_notification_pager_keeps_loaded_updates_and_retries_the_same_page_after_failure():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const states = [];
|
||||
const statuses = [];
|
||||
const requested = [];
|
||||
const pager = buildMyWork.createNotificationPager({{
|
||||
load: async page => {{ requested.push(page); throw new Error('offline'); }},
|
||||
onNotifications: items => states.push(items),
|
||||
onPagination: () => {{}},
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
pager.reset({{page:2, total:125, has_more:true}});
|
||||
pager.loadMore([{{id:1}}]).then(result =>
|
||||
pager.loadMore([{{id:1}}]).then(retry =>
|
||||
process.stdout.write(JSON.stringify({{requested, states, statuses, result, retry}}))
|
||||
)
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"requested": [3, 3],
|
||||
"states": [],
|
||||
"statuses": [
|
||||
"Loading older updates…", "Could not load older updates. Retry.",
|
||||
"Loading older updates…", "Could not load older updates. Retry.",
|
||||
],
|
||||
"result": False,
|
||||
"retry": False,
|
||||
}
|
||||
|
||||
|
||||
def test_notification_reader_marks_current_read_and_opens_next_update():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const original = [
|
||||
{{kind:'update', key:'repo#1', notification_id:42, has_update:true, title:'First'}},
|
||||
{{kind:'issue', key:'repo#2', notification_id:43, has_update:true, title:'Second'}},
|
||||
];
|
||||
const loaded = [];
|
||||
const opened = [];
|
||||
const details = [];
|
||||
const states = [];
|
||||
const statuses = [];
|
||||
const closed = [];
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: async id => {{ loaded.push(id); return {{id, latest_comment:{{body:'Comment ' + id}}}}; }},
|
||||
markRead: async id => id,
|
||||
onOpen: item => opened.push(item.notification_id),
|
||||
onDetail: detail => details.push(detail.id),
|
||||
onItems: items => states.push(items),
|
||||
onStatus: status => statuses.push(status),
|
||||
onClose: () => closed.push(true),
|
||||
}});
|
||||
reader.open(original[0], original).then(() =>
|
||||
reader.markReadAndNext(original).then(result =>
|
||||
process.stdout.write(JSON.stringify({{loaded, opened, details, states, statuses, closed, result}}))
|
||||
)
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["loaded"] == [42, 43]
|
||||
assert output["opened"] == [42, 43]
|
||||
assert output["details"] == [42, 43]
|
||||
assert output["states"] == [[
|
||||
{"kind": "issue", "key": "repo#2", "notification_id": 43,
|
||||
"has_update": True, "title": "Second"},
|
||||
]]
|
||||
assert output["statuses"] == [
|
||||
"Loading update…", "Update ready.", "Marking update read…",
|
||||
"Loading update…", "Update ready.",
|
||||
]
|
||||
assert output["closed"] == []
|
||||
assert output["result"]["next"]["notification_id"] == 43
|
||||
|
||||
|
||||
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const item = {{kind:'update', notification_id:42, has_update:true}};
|
||||
const events = [];
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: async () => {{ throw new Error('offline'); }},
|
||||
markRead: async () => {{}}, onOpen: () => events.push('open'),
|
||||
onDetail: () => events.push('detail'), onItems: () => events.push('items'),
|
||||
onStatus: status => events.push(status), onClose: () => events.push('close'),
|
||||
}});
|
||||
reader.open(item, [item]).then(result =>
|
||||
process.stdout.write(JSON.stringify({{result, events}}))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": False,
|
||||
"events": [
|
||||
"open", "Loading update…",
|
||||
"Could not load update. Retry or open it in Gitea.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_notification_reader_does_not_advance_or_remove_item_when_mark_read_fails():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const item = {{kind:'update', notification_id:42, has_update:true}};
|
||||
const events = [];
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: async id => ({{id}}), markRead: async () => {{ throw new Error('offline'); }},
|
||||
onOpen: () => events.push('open'), onDetail: () => events.push('detail'),
|
||||
onItems: () => events.push('items'), onStatus: status => events.push(status),
|
||||
onClose: () => events.push('close'),
|
||||
}});
|
||||
reader.open(item, [item]).then(() =>
|
||||
reader.markReadAndNext([item]).then(result =>
|
||||
process.stdout.write(JSON.stringify({{result, events}}))
|
||||
)
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": False,
|
||||
"events": [
|
||||
"open", "Loading update…", "detail", "Update ready.",
|
||||
"Marking update read…", "Could not mark update read. Retry.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_notification_reader_wraps_to_an_earlier_visible_unread_update():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = [
|
||||
{{kind:'update', notification_id:42, has_update:true}},
|
||||
{{kind:'update', notification_id:43, has_update:true}},
|
||||
];
|
||||
const opened = [];
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: async id => ({{id}}), markRead: async () => {{}},
|
||||
onOpen: item => opened.push(item.notification_id), onDetail: () => {{}},
|
||||
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
|
||||
}});
|
||||
reader.open(items[1], items).then(() => reader.markReadAndNext(items)).then(result =>
|
||||
process.stdout.write(JSON.stringify({{opened, next:result.next.notification_id}}))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"opened": [43, 42], "next": 42}
|
||||
|
||||
|
||||
def test_notification_reader_closes_and_announces_when_final_update_is_cleared():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const item = {{kind:'update', notification_id:42, has_update:true}};
|
||||
const events = [];
|
||||
const reader = buildMyWork.createNotificationReader({{
|
||||
load: async id => ({{id}}), markRead: async () => {{}},
|
||||
onOpen: () => {{}}, onDetail: () => {{}}, onItems: items => events.push(['items', items]),
|
||||
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
|
||||
}});
|
||||
reader.open(item, [item]).then(() => {{
|
||||
events.length = 0;
|
||||
return reader.markReadAndNext([item]);
|
||||
}}).then(result => process.stdout.write(JSON.stringify({{result, events}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["result"] == {"items": [], "next": None}
|
||||
assert output["events"] == [
|
||||
["status", "Marking update read…"],
|
||||
["items", []],
|
||||
["close"],
|
||||
["status", "Inbox cleared."],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
|
||||
html = await dashboard()
|
||||
|
||||
assert html.index('id="my-work"') < html.index('data-panel-key="context"')
|
||||
assert 'data-work-filter="all"' in html
|
||||
assert 'data-work-filter="issue"' in html
|
||||
assert 'data-work-filter="pull"' 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 '.my-work-card' in html and 'min-height: 44px' in html
|
||||
assert '<script src="static/my-work.js"></script>' in html
|
||||
assert "buildMyWork(data)" in html
|
||||
assert "markMyWorkStale()" in html
|
||||
assert "filterMyWork(lastMyWork, selectedWorkFilter)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_cards_offer_accessible_mobile_mark_read_without_nested_actions():
|
||||
html = await dashboard()
|
||||
|
||||
assert '.mark-update-read' in html and 'min-height:44px' in html
|
||||
assert 'data-notification-id' in html
|
||||
assert 'Unread update' in html
|
||||
assert 'id="my-work-action-status"' in html and 'aria-live="assertive"' in html
|
||||
assert "createNotificationAcknowledger" in html
|
||||
assert "method: 'PATCH'" in html
|
||||
assert "api/v1/notifications/" in html
|
||||
assert '<a class="my-work-card"' not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="update-sheet"' in html and 'aria-modal="true"' in html
|
||||
assert 'class="read-update"' in html
|
||||
assert 'id="keep-update-unread"' in html
|
||||
assert 'id="mark-update-read-next"' in html
|
||||
assert 'id="retry-update-load"' in html
|
||||
assert 'id="update-comment-body"' in html
|
||||
assert 'id="update-subject-body"' in html
|
||||
assert 'id="open-update-gitea"' in html
|
||||
assert '.update-sheet-panel { width:min(560px,100%);' in html
|
||||
assert '.update-sheet-content { overflow-wrap:anywhere;' in html
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
|
||||
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
||||
assert "createNotificationReader" in html
|
||||
assert "api/v1/notifications/" in html
|
||||
assert "notificationReader.markReadAndNext(lastMyWork)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="bulk-mark-read"' in html
|
||||
assert 'id="bulk-mark-read-bar"' in html
|
||||
assert 'class="my-work-bulk"' in html
|
||||
assert '.my-work-bulk { position:sticky;' in html
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
|
||||
assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
|
||||
assert "'next ' + ids.length + ' of ' + allIds.length + ' loaded updates'" in html
|
||||
assert "'Confirm marking ' + bulkLabel + ' read'" in html
|
||||
assert "const ids = allIds.slice(0, 50)" in html
|
||||
assert "createBulkNotificationAcknowledger" in html
|
||||
assert "api/v1/notifications/read" in html
|
||||
assert "body: JSON.stringify({ ids })" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="notification-page-status"' in html
|
||||
assert 'id="load-more-notifications"' in html
|
||||
assert '.load-more-notifications' in html and 'min-height:44px' in html
|
||||
assert "createNotificationPager" in html
|
||||
assert "api/v1/notifications?page=" in html
|
||||
assert "snapshot.notification_pagination" in html
|
||||
assert "notificationPager.loadMore(lastNotifications)" in html
|
||||
assert "Math.min(lastNotifications.length, 50)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
html = await dashboard()
|
||||
|
||||
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
|
||||
assert 'data-work-count="all"' in html
|
||||
assert 'data-work-count="issue"' in html
|
||||
assert 'data-work-count="pull"' 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.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||
|
||||
|
||||
def test_review_controller_loads_encoded_cross_repo_detail_path():
|
||||
script = f"""
|
||||
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
|
||||
let request;
|
||||
const controller = createReviewController({{ fetchJson: async (url, options) => {{
|
||||
request = {{ url, accept: options.headers.Accept }};
|
||||
return {{ title: 'Review API' }};
|
||||
}} }});
|
||||
controller.load({{ repository: 'stackchain/api', number: 7 }}).then(detail =>
|
||||
process.stdout.write(JSON.stringify({{ request, title: detail.title }}))
|
||||
);
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"request": {
|
||||
"url": "api/v1/repos/stackchain/api/pulls/7/review",
|
||||
"accept": "application/json",
|
||||
},
|
||||
"title": "Review API",
|
||||
}
|
||||
|
||||
|
||||
def test_review_diff_rows_escape_content_and_toggle_accessibly():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const escapeHtml = value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
const html = reviewSheet.renderDiffFile({{
|
||||
filename: 'src/<api>.py', status: 'modified', additions: 1, deletions: 1,
|
||||
diff_available: true, diff_truncated: true,
|
||||
diff_lines: ['@@ -1 +1 @@', '-old <token>', '+new & safe']
|
||||
}}, 2, escapeHtml);
|
||||
const button = {{ attrs: {{ 'aria-expanded': 'false' }}, getAttribute(k) {{ return this.attrs[k]; }}, setAttribute(k,v) {{ this.attrs[k]=v; }} }};
|
||||
const panel = {{ hidden: true }};
|
||||
reviewSheet.toggleDiff(button, panel);
|
||||
process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expanded'], hidden: panel.hidden }}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert 'aria-expanded="false"' in output["html"]
|
||||
assert 'src/<api>.py' in output["html"]
|
||||
assert '-old <token>' in output["html"]
|
||||
assert '+new & safe' in output["html"]
|
||||
assert 'Preview truncated' in output["html"]
|
||||
assert 'class="review-mark"' in output["html"]
|
||||
assert 'data-review-filename="src/<api>.py"' in output["html"]
|
||||
assert 'Mark reviewed' in output["html"]
|
||||
assert output["expanded"] == "true"
|
||||
assert output["hidden"] is False
|
||||
|
||||
|
||||
def test_review_progress_is_explicit_and_restores_for_the_same_head_sha():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem(key) {{ return values.has(key) ? values.get(key) : null; }},
|
||||
setItem(key, value) {{ values.set(key, value); }}
|
||||
}};
|
||||
const options = {{
|
||||
storage, repository: 'stackchain/api', number: 7, headSha: 'abc123',
|
||||
files: [{{filename:'src/a.py'}}, {{filename:'src/b.py'}}, {{filename:'README.md'}}]
|
||||
}};
|
||||
const first = reviewSheet.createProgress(options);
|
||||
const before = first.snapshot();
|
||||
const marked = first.markReviewed('src/a.py');
|
||||
const restored = reviewSheet.createProgress(options).snapshot();
|
||||
process.stdout.write(JSON.stringify({{ before, marked, restored }}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["before"] == {
|
||||
"reviewed": [], "reviewedCount": 0, "total": 3, "nextFilename": "src/a.py"
|
||||
}
|
||||
assert output["marked"] == {
|
||||
"reviewed": ["src/a.py"], "reviewedCount": 1, "total": 3,
|
||||
"nextFilename": "src/b.py",
|
||||
}
|
||||
assert output["restored"] == output["marked"]
|
||||
|
||||
|
||||
def test_review_progress_resets_when_new_commits_change_the_head_sha():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem(key) {{ return values.has(key) ? values.get(key) : null; }},
|
||||
setItem(key, value) {{ values.set(key, value); }}
|
||||
}};
|
||||
const base = {{ storage, repository: 'stackchain/api', number: 7, files: [{{filename:'src/a.py'}}] }};
|
||||
reviewSheet.createProgress({{...base, headSha:'abc123'}}).markReviewed('src/a.py');
|
||||
const changed = reviewSheet.createProgress({{...base, headSha:'def456'}}).snapshot();
|
||||
process.stdout.write(JSON.stringify(changed));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"reviewed": [], "reviewedCount": 0, "total": 1,
|
||||
"nextFilename": "src/a.py", "newHead": True,
|
||||
}
|
||||
|
||||
|
||||
def test_review_feedback_draft_restores_notes_summary_and_decision_for_same_head():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem(key) {{ return values.has(key) ? values.get(key) : null; }},
|
||||
setItem(key, value) {{ values.set(key, value); }}
|
||||
}};
|
||||
const options = {{
|
||||
storage, repository: 'stackchain/api', number: 7, headSha: 'abc123',
|
||||
files: [{{filename:'src/a.py'}}, {{filename:'src/b.py'}}]
|
||||
}};
|
||||
const first = reviewSheet.createDraft(options);
|
||||
first.setNote('src/a.py', 'Handle the empty state.');
|
||||
first.setSummary('One blocker remains.');
|
||||
first.setDecision('request_changes');
|
||||
const restored = reviewSheet.createDraft(options).snapshot();
|
||||
process.stdout.write(JSON.stringify(restored));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"notes": {"src/a.py": "Handle the empty state."},
|
||||
"summary": "One blocker remains.",
|
||||
"decision": "request_changes",
|
||||
}
|
||||
|
||||
|
||||
def test_review_feedback_formats_non_empty_file_notes_in_changed_file_order():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const markdown = reviewSheet.formatFeedback({{
|
||||
decision: 'request_changes', summary: 'One blocker remains.',
|
||||
notes: {{'src/b.py':'Second note', 'src/a.py':'First note', 'README.md':''}}
|
||||
}}, [{{filename:'src/a.py'}}, {{filename:'README.md'}}, {{filename:'src/b.py'}}]);
|
||||
process.stdout.write(markdown);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.stdout == (
|
||||
"## Intended decision\nRequest changes\n\n"
|
||||
"## Summary\nOne blocker remains.\n\n"
|
||||
"## File notes\n### `src/a.py`\nFirst note\n\n"
|
||||
"### `src/b.py`\nSecond note"
|
||||
)
|
||||
|
||||
|
||||
def test_review_handoff_reserves_window_before_copy_and_reports_blocked_popup():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const events = [];
|
||||
let finishCopy;
|
||||
const reserved = {{ location: {{ href: '' }}, close: () => events.push('close') }};
|
||||
const success = reviewSheet.copyAndContinue({{
|
||||
text: 'feedback', url: 'https://forge.example/pulls/7',
|
||||
copy: text => new Promise(resolve => {{ events.push('copy:' + text); finishCopy = resolve; }}),
|
||||
open: () => {{ events.push('reserve'); return reserved; }},
|
||||
fallback: text => events.push('fallback:' + text),
|
||||
}});
|
||||
const beforeCopySettles = events.slice();
|
||||
finishCopy();
|
||||
const blockedEvents = [];
|
||||
const blocked = reviewSheet.copyAndContinue({{
|
||||
text: 'copied', url: 'https://forge.example/pulls/8',
|
||||
copy: async () => blockedEvents.push('copy'),
|
||||
open: () => {{ blockedEvents.push('reserve'); return null; }},
|
||||
fallback: text => blockedEvents.push('fallback:' + text),
|
||||
}});
|
||||
Promise.all([success, blocked]).then(results => process.stdout.write(JSON.stringify({{
|
||||
beforeCopySettles, events, blockedEvents, destination: reserved.location.href, results
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"beforeCopySettles": ["reserve", "copy:feedback"],
|
||||
"events": ["reserve", "copy:feedback"],
|
||||
"blockedEvents": ["reserve", "copy", "fallback:copied"],
|
||||
"destination": "https://forge.example/pulls/7",
|
||||
"results": [
|
||||
{"copied": True, "opened": True},
|
||||
{"copied": True, "opened": False},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_review_handoff_closes_reserved_window_when_copy_fails():
|
||||
script = f"""
|
||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||
const events = [];
|
||||
reviewSheet.copyAndContinue({{
|
||||
text: 'keep me', url: 'https://forge.example/pulls/8',
|
||||
copy: async () => {{ throw new Error('denied'); }},
|
||||
open: () => ({{ close: () => events.push('close') }}),
|
||||
fallback: text => events.push('fallback:' + text),
|
||||
}}).then(result => process.stdout.write(JSON.stringify({{ events, result }})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"events": ["close", "fallback:keep me"],
|
||||
"result": {"copied": False, "opened": False},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_requests_open_an_accessible_mobile_detail_sheet():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="review-sheet"' in html
|
||||
assert 'role="dialog"' in html and 'aria-modal="true"' in html
|
||||
assert 'id="review-sheet-status"' in html and 'aria-live="polite"' in html
|
||||
assert 'id="open-review-gitea"' in html and 'rel="noopener noreferrer"' in html
|
||||
assert '<script src="static/review-sheet.js"></script>' in html
|
||||
assert '@media (max-width: 600px)' in html
|
||||
assert '.review-sheet-panel' in html and 'width:100%' in html
|
||||
assert '.review-action' in html and 'min-height:44px' in html
|
||||
assert '.review-file-toggle' in html and 'min-height:44px' in html
|
||||
assert '.review-mark' in html and 'min-height:44px' in html
|
||||
assert 'id="review-progress"' in html and 'aria-live="polite"' in html
|
||||
assert 'id="next-unreviewed-review"' in html
|
||||
assert '.review-progress-actions' in html and 'position:sticky' in html
|
||||
assert "createReviewController.createProgress" in html
|
||||
assert "progress.markReviewed" in html
|
||||
assert "scrollIntoView" in html
|
||||
assert '.review-diff' in html and 'overflow-x:auto' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'data-review-index' in html
|
||||
assert "reviewController.load(selectedReview)" in html
|
||||
assert "review-files" in html
|
||||
assert "review-history" in html
|
||||
assert "open-review-gitea" in html
|
||||
assert "reviewController.submit" not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_review_sheet_captures_and_safely_hands_off_feedback():
|
||||
html = await dashboard()
|
||||
review_script = REVIEW_SHEET.read_text()
|
||||
|
||||
assert 'class="review-note"' in review_script
|
||||
assert 'id="review-decision"' in html
|
||||
assert 'id="review-summary"' in html
|
||||
assert 'id="copy-review-feedback"' in html
|
||||
assert 'id="review-copy-fallback"' in html
|
||||
assert 'id="review-handoff-link"' in html
|
||||
assert '.review-handoff-link' in html and 'min-height:44px' in html
|
||||
assert '.review-handoff-link[hidden]' in html and 'display:none' in html
|
||||
assert '.review-note' in html and 'min-height:88px' in html
|
||||
assert '.review-handoff' in html and 'position:sticky' in html
|
||||
assert "createReviewController.createDraft" in html
|
||||
assert "draft.setNote" in html
|
||||
assert "draft?.setSummary" in html
|
||||
assert "draft?.setDecision" in html
|
||||
assert "createReviewController.formatFeedback" in html
|
||||
assert "createReviewController.copyAndContinue" in html
|
||||
assert "navigator.clipboard.writeText" in html
|
||||
assert "window.open('about:blank', '_blank')" in html
|
||||
assert "handoffWindow.opener = null" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="retry-review-load"' in html
|
||||
assert '.review-retry' in html and 'min-height:44px' in html
|
||||
assert "qs('#retry-review-load').hidden = false" in html
|
||||
assert "qs('#retry-review-load').hidden = true" in html
|
||||
assert "openReviewSheet(selectedReview, reviewTrigger)" in html
|
||||
assert "qs('#retry-review-load').focus()" in html
|
||||
@@ -0,0 +1,76 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_api_is_bounded_and_never_cacheable(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def detail(thread_id):
|
||||
calls.append(thread_id)
|
||||
return {
|
||||
"id": thread_id,
|
||||
"repository": "stackchain/api",
|
||||
"title": "Retry failed deploy",
|
||||
"subject_type": "Issue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
"subject_body": "Deploy fails after three retries.",
|
||||
"latest_comment": {
|
||||
"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",
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "notification_detail", detail, 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")
|
||||
invalid = await client.get("/api/v1/notifications/0")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["latest_comment"]["author"] == "alexander"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert invalid.status_code == 422
|
||||
assert calls == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch):
|
||||
async def detail(_thread_id):
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
monkeypatch.setattr(main, "notification_detail", detail)
|
||||
monkeypatch.setattr(main, "NOTIFICATION_DETAIL_TIMEOUT_SECONDS", 0.01)
|
||||
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/42")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"error": "Loading the update timed out. Please retry."}
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_detail_upstream_failure_does_not_leak_exception(monkeypatch):
|
||||
async def detail(_thread_id):
|
||||
raise httpx.HTTPError("token=secret upstream exploded")
|
||||
|
||||
monkeypatch.setattr(main, "notification_detail", detail)
|
||||
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/42")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {
|
||||
"error": "The update is temporarily unavailable. Please retry."
|
||||
}
|
||||
assert "secret" not in response.text
|
||||
assert response.headers["retry-after"] == "1"
|
||||
@@ -0,0 +1,33 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_page_endpoint_loads_only_requested_page_and_is_not_cacheable(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def page_loader(page):
|
||||
requested.append(page)
|
||||
return {
|
||||
"items": [{"id": 51, "title": "Older update"}],
|
||||
"page": page,
|
||||
"total": 125,
|
||||
"has_more": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "notification_page", page_loader)
|
||||
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?page=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"items": [{"id": 51, "title": "Older update"}],
|
||||
"page": 2,
|
||||
"total": 125,
|
||||
"has_more": True,
|
||||
}
|
||||
assert requested == [2]
|
||||
@@ -0,0 +1,241 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
class Client:
|
||||
async def patch(self, path, headers):
|
||||
calls.append((path, headers))
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
|
||||
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
|
||||
|
||||
await gitea_proxy.mark_notification_read(42)
|
||||
|
||||
assert calls == [
|
||||
("/api/v1/notifications/threads/42?to-status=read", {"Authorization": "token test"})
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeypatch):
|
||||
marked = []
|
||||
|
||||
async def mark(thread_id):
|
||||
marked.append(thread_id)
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch("/api/v1/notifications/42/read")
|
||||
invalid = await client.patch("/api/v1/notifications/0/read")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": 42, "status": "read"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert invalid.status_code == 422
|
||||
assert marked == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bulk_mark_read_reports_partial_progress_and_retains_only_failures(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def mark(thread_id):
|
||||
calls.append(thread_id)
|
||||
if thread_id == 43:
|
||||
raise httpx.HTTPError("upstream unavailable")
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_live_snapshot_value",
|
||||
{"notifications": [{"id": 42}, {"id": 43}, {"id": 44}]},
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/notifications/read", json={"ids": [42, 43, 42, 44]}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"marked": [42, 44], "failed": [43]}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert sorted(calls) == [42, 43, 44]
|
||||
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bulk_mark_read_limits_upstream_concurrency(monkeypatch):
|
||||
active = 0
|
||||
peak_active = 0
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(main, "_read_notification_ids", set())
|
||||
monkeypatch.setattr(main, "_live_snapshot_value", None)
|
||||
|
||||
async def mark(thread_id):
|
||||
nonlocal active, peak_active
|
||||
calls.append(thread_id)
|
||||
active += 1
|
||||
peak_active = max(peak_active, active)
|
||||
if active == 5:
|
||||
started.set()
|
||||
try:
|
||||
await release.wait()
|
||||
finally:
|
||||
active -= 1
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
request = asyncio.create_task(
|
||||
client.patch("/api/v1/notifications/read", json={"ids": list(range(1, 51))})
|
||||
)
|
||||
await asyncio.wait_for(started.wait(), timeout=1)
|
||||
await asyncio.sleep(0)
|
||||
assert peak_active == 5
|
||||
assert len(calls) == 5
|
||||
release.set()
|
||||
response = await request
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"marked": list(range(1, 51)), "failed": []}
|
||||
assert sorted(calls) == list(range(1, 51))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bulk_mark_read_cancels_unfinished_work_at_batch_deadline(monkeypatch):
|
||||
active = 0
|
||||
cancelled = []
|
||||
|
||||
async def mark(thread_id):
|
||||
nonlocal active
|
||||
active += 1
|
||||
try:
|
||||
await asyncio.sleep(0.08)
|
||||
except asyncio.CancelledError:
|
||||
cancelled.append(thread_id)
|
||||
raise
|
||||
finally:
|
||||
active -= 1
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
monkeypatch.setattr(main, "BULK_NOTIFICATION_DEADLINE_SECONDS", 0.01, raising=False)
|
||||
monkeypatch.setattr(main, "_read_notification_ids", set())
|
||||
monkeypatch.setattr(main, "_live_snapshot_value", None)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
started_at = time.monotonic()
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/notifications/read", json={"ids": list(range(1, 11))}
|
||||
)
|
||||
elapsed = time.monotonic() - started_at
|
||||
|
||||
assert elapsed < 0.06
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"marked": [], "failed": list(range(1, 11))}
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert active == 0
|
||||
assert sorted(cancelled) == list(range(1, 6))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bulk_mark_read_rejects_empty_invalid_and_oversized_batches(monkeypatch):
|
||||
marked = []
|
||||
|
||||
async def mark(thread_id):
|
||||
marked.append(thread_id)
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
empty = await client.patch("/api/v1/notifications/read", json={"ids": []})
|
||||
invalid = await client.patch(
|
||||
"/api/v1/notifications/read", json={"ids": [42, 0]}
|
||||
)
|
||||
oversized = await client.patch(
|
||||
"/api/v1/notifications/read", json={"ids": list(range(1, 52))}
|
||||
)
|
||||
|
||||
assert [empty.status_code, invalid.status_code, oversized.status_code] == [
|
||||
422,
|
||||
422,
|
||||
422,
|
||||
]
|
||||
assert all(response.headers["cache-control"] == "no-store" for response in [empty, invalid, oversized])
|
||||
assert marked == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_read_removes_thread_from_retained_live_snapshot(monkeypatch):
|
||||
async def mark(_thread_id):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_live_snapshot_value",
|
||||
{
|
||||
"context": {},
|
||||
"events": [],
|
||||
"notifications": [{"id": 42}, {"id": 43}],
|
||||
"sections": {"notifications": "fresh"},
|
||||
},
|
||||
)
|
||||
|
||||
response = await main.read_notification(42)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert main._live_snapshot_value is not None
|
||||
assert main._live_snapshot_value["notifications"] == [{"id": 43}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_inflight_refresh_cannot_restore_a_notification_marked_read(monkeypatch):
|
||||
refresh_started = asyncio.Event()
|
||||
release_refresh = asyncio.Event()
|
||||
|
||||
async def mark(_thread_id):
|
||||
return None
|
||||
|
||||
async def stale_upstream_snapshot():
|
||||
refresh_started.set()
|
||||
await release_refresh.wait()
|
||||
return {
|
||||
"context": {},
|
||||
"events": [],
|
||||
"notifications": [{"id": 42}, {"id": 43}],
|
||||
"sections": {"notifications": "fresh"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||
monkeypatch.setattr(
|
||||
main, "_build_live_snapshot_before_deadline", stale_upstream_snapshot
|
||||
)
|
||||
monkeypatch.setattr(main, "_live_snapshot_task", None)
|
||||
monkeypatch.setattr(main, "_live_snapshot_value", {"notifications": [{"id": 42}]})
|
||||
|
||||
refresh = main._start_live_snapshot_refresh()
|
||||
await refresh_started.wait()
|
||||
await main.read_notification(42)
|
||||
release_refresh.set()
|
||||
await refresh
|
||||
|
||||
assert main._live_snapshot_value is not None
|
||||
assert main._live_snapshot_value["notifications"] == [{"id": 43}]
|
||||
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DASHBOARD = Path("frontend/index.html")
|
||||
|
||||
|
||||
def test_dashboard_panels_are_independently_collapsible():
|
||||
html = DASHBOARD.read_text()
|
||||
|
||||
for panel_key in ("context", "ai-stream", "issues", "pull-requests", "widgets", "layout", "markdown"):
|
||||
assert f'<details class="panel' in html
|
||||
assert f'data-panel-key="{panel_key}"' in html
|
||||
|
||||
|
||||
def test_panel_open_state_is_restored_and_saved_in_local_storage():
|
||||
html = DASHBOARD.read_text()
|
||||
|
||||
assert 'const PANEL_STATE_KEY = "stackchain.panel-state.v1"' in html
|
||||
assert "localStorage.getItem(PANEL_STATE_KEY)" in html
|
||||
assert "panel.open = savedState[panel.dataset.panelKey]" in html
|
||||
assert "localStorage.setItem(PANEL_STATE_KEY" in html
|
||||
assert "panel.addEventListener('toggle'" in html
|
||||
@@ -0,0 +1,34 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
README = Path(__file__).parents[1] / "README.md"
|
||||
REQUIREMENTS = Path(__file__).parents[1] / "requirements.txt"
|
||||
|
||||
|
||||
def test_release_worker_instructions_use_direct_agent_execution():
|
||||
text = README.read_text()
|
||||
|
||||
assert "codex" not in text.lower()
|
||||
assert "RELEASE_AGENT_COMMAND=true" in text
|
||||
assert "--issue 19" in text
|
||||
|
||||
|
||||
def test_readme_documents_a_complete_local_quickstart():
|
||||
text = README.read_text()
|
||||
|
||||
assert "python3 -m venv .venv" in text
|
||||
assert "pip install -r requirements.txt" in text
|
||||
assert "GITEA_URL" in text
|
||||
assert "GITEA_TOKEN" in text
|
||||
assert "uvicorn src.main:app" in text
|
||||
assert "http://127.0.0.1:8000/api/v1/context" in text
|
||||
assert "uvicorn==" in REQUIREMENTS.read_text()
|
||||
|
||||
|
||||
def test_readme_documents_liveness_and_gitea_readiness_checks():
|
||||
text = README.read_text()
|
||||
|
||||
assert "`/healthz`" in text
|
||||
assert "`/readyz`" in text
|
||||
assert "does not contact Gitea" in text
|
||||
assert "HTTP 503" in text
|
||||
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -6,6 +8,7 @@ import pytest
|
||||
from src.release_engine import (
|
||||
CommandResult,
|
||||
EngineConfig,
|
||||
GiteaClient,
|
||||
Issue,
|
||||
ReleaseEngine,
|
||||
RunState,
|
||||
@@ -15,6 +18,28 @@ from src.release_engine import (
|
||||
)
|
||||
|
||||
|
||||
def test_stale_token_error_explains_how_to_restore_cron_authentication():
|
||||
class UnauthorizedHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(401)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"message":"user does not exist [uid: 0, name: ]"}')
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), UnauthorizedHandler)
|
||||
thread = threading.Thread(target=server.handle_request)
|
||||
thread.start()
|
||||
try:
|
||||
client = GiteaClient(f"http://127.0.0.1:{server.server_port}", "stale-token")
|
||||
with pytest.raises(RuntimeError, match="refresh the cron GITEA_TOKEN"):
|
||||
client.list_open_issues("stackchain/stackchain-dashboard")
|
||||
finally:
|
||||
thread.join(timeout=2)
|
||||
server.server_close()
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, issues, verified_assignee="timmy", existing_pr=None):
|
||||
self.issues = issues
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
WORKFLOW = Path(".gitea/workflows/release.yml")
|
||||
|
||||
|
||||
def test_release_workflow_uses_gitea_api_not_github_cli():
|
||||
text = WORKFLOW.read_text()
|
||||
assert "gh release" not in text
|
||||
assert "/api/v1/repos/${{ github.repository }}/tags" in text
|
||||
assert "/api/v1/repos/${{ github.repository }}/releases" in text
|
||||
|
||||
|
||||
def test_release_workflow_targets_merge_commit_and_marks_rc():
|
||||
text = WORKFLOW.read_text()
|
||||
assert 'TARGET="${{ github.sha }}"' in text
|
||||
assert '"prerelease":true' in text
|
||||
assert '"draft":false' in text
|
||||
|
||||
|
||||
def test_release_workflow_fails_on_api_error():
|
||||
text = WORKFLOW.read_text()
|
||||
assert "curl --fail-with-body" in text
|
||||
assert "|| true" not in text
|
||||
@@ -0,0 +1,18 @@
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_python_bytecode_is_not_tracked():
|
||||
tracked = subprocess.run(
|
||||
["git", "ls-files"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.splitlines()
|
||||
|
||||
bytecode = [
|
||||
path
|
||||
for path in tracked
|
||||
if "__pycache__" in path.split("/") or path.endswith((".pyc", ".pyo"))
|
||||
]
|
||||
|
||||
assert bytecode == []
|
||||
@@ -0,0 +1,88 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_detail_endpoint_returns_normalized_no_store_payload(monkeypatch):
|
||||
async def requested(repository, number):
|
||||
return (repository, number) == ("stackchain/api", 7)
|
||||
|
||||
async def detail(repository, number):
|
||||
assert (repository, number) == ("stackchain/api", 7)
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": "Review API",
|
||||
"body": "Check retries",
|
||||
"url": "https://forge.example/stackchain/api/pulls/7",
|
||||
"author": "alex",
|
||||
"ci_state": "success",
|
||||
"files": [{"filename": "src/api.py", "status": "modified", "additions": 8, "deletions": 2}],
|
||||
"reviews": [{"user": {"login": "sam"}, "state": "APPROVED", "body": "Good"}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main, "pull_review_detail", detail)
|
||||
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/review")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["files"][0]["filename"] == "src/api.py"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_detail_rejects_pulls_not_requested_from_service_user(monkeypatch):
|
||||
async def requested(repository, number):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
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/private/secret/pulls/9/review")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_detail_has_one_retryable_deadline_and_cancels_pending_work(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def requested(repository, number):
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main, "REVIEW_DETAIL_TIMEOUT_SECONDS", 0.01)
|
||||
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/review")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.json() == {
|
||||
"error": "Pull request review details timed out. Please retry."
|
||||
}
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_public_review_endpoint_does_not_expose_service_token_mutations():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={"action": "approve"},
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_one_context_snapshot_updates_every_context_backed_panel():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/context-poller.js"></script>' in html
|
||||
assert "createContextPoller({" in html
|
||||
assert "renderContextSnapshot(data)" in html
|
||||
assert "renderRepoMix(qs('#repo-mix'), data)" in html
|
||||
assert "setInterval(tickWidgets, 2000)" not in html
|
||||
assert "updateRepoMix(qs('#repo-mix'), fetch)" not in html
|
||||
assert "setInterval(load, 8000)" not in html
|
||||
assert "document.addEventListener('visibilitychange'" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_one_live_snapshot_updates_work_and_activity_on_one_timer():
|
||||
html = await dashboard()
|
||||
|
||||
assert "fetch('api/v1/live'" in html
|
||||
assert "renderLiveSnapshot(snapshot)" in html
|
||||
assert "renderContextSnapshot(snapshot.context)" in html
|
||||
assert "paintEventStream(snapshot.events)" in html
|
||||
assert "loadEventStream" not in html
|
||||
assert "5000" not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_announces_when_recent_snapshot_is_revalidating():
|
||||
html = await dashboard()
|
||||
|
||||
assert "snapshot.freshness?.revalidating" in html
|
||||
assert "Refreshing · showing recent snapshot" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_announces_failed_refresh_and_retry_without_blanking_panels():
|
||||
html = await dashboard()
|
||||
|
||||
assert "snapshot.freshness?.degraded" in html
|
||||
assert "Refresh failed · showing last known data" in html
|
||||
assert "snapshot.freshness.retry_in_seconds" in html
|
||||
@@ -0,0 +1,21 @@
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_outside_repository_working_directory(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
html = await dashboard()
|
||||
|
||||
assert "Stackchain Dashboard" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_viewport_allows_mobile_zoom():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'name="viewport"' in html
|
||||
assert "width=device-width" in html
|
||||
assert "user-scalable=no" not in html
|
||||
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
WIDGETS = Path(__file__).parents[1] / "frontend" / "widgets.js"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_repo_mix_placeholder_before_javascript_runs():
|
||||
html = await dashboard()
|
||||
server_rendered_html = html.split("<script>", 1)[0]
|
||||
|
||||
assert '<div class="small" id="repo-mix">Loading…</div>' in server_rendered_html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_marks_fallback_context_as_degraded_instead_of_live():
|
||||
html = await dashboard()
|
||||
|
||||
assert "data.error ? 'Degraded · ' + data.error : 'Live · updated just now'" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_does_not_offer_fabricated_work_when_context_fetch_fails():
|
||||
html = await dashboard()
|
||||
|
||||
assert "Work items unavailable." in html
|
||||
assert "Kickoff issue" not in html
|
||||
assert "Live UI implementation" not in html
|
||||
assert "Docker build and push" not in html
|
||||
assert "Agent UI delta binding" not in html
|
||||
|
||||
|
||||
def test_repo_mix_reports_unavailable_for_failed_context_response():
|
||||
script = f"""
|
||||
const updateRepoMix = require({json.dumps(str(WIDGETS))});
|
||||
const element = {{ innerHTML: 'stale counts' }};
|
||||
const failedFetch = async () => ({{
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({{ error: 'upstream unavailable' }}),
|
||||
}});
|
||||
|
||||
updateRepoMix(element, failedFetch).then(() => {{
|
||||
if (!element.innerHTML.includes('Widget unavailable.')) {{
|
||||
throw new Error(`expected unavailable state, got: ${{element.innerHTML}}`);
|
||||
}}
|
||||
}});
|
||||
"""
|
||||
|
||||
subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
Reference in new issue
Block a user