Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c889e3ffc | ||
|
|
1c0cacb2b8 | ||
|
|
30a014b619 | ||
|
|
9b81dce8aa | ||
|
|
7f5e671f8d | ||
|
|
83de2a7499 | ||
|
|
62ebabb132 | ||
|
|
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 |
No files matched your search
@@ -14,11 +14,17 @@ python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
||||
read-scoped access token, then start the API and bundled frontend:
|
||||
token that can read dashboard data, update the authenticated user's notification
|
||||
threads, create issue comments, close assigned issues, and submit pull-request
|
||||
reviews. Pull-request replies and mobile My Work issue comments use Gitea's
|
||||
issue-comment API; closing an assigned issue and native Comment, Approve, and
|
||||
Request changes reviews require repository write permission. Serve the dashboard
|
||||
only to trusted users on its own origin; cross-origin API
|
||||
access is intentionally disabled. Then start the API and bundled frontend:
|
||||
|
||||
```bash
|
||||
export GITEA_URL='https://forge.example.com'
|
||||
export GITEA_TOKEN='<read-scoped-token>'
|
||||
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
|
||||
+768
-26
@@ -60,10 +60,17 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filter { min-height: 44px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.my-work-card { min-height: 44px; display:block; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.my-work-card.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.mark-update-read { min-height:44px; width:100%; }
|
||||
.read-update { min-height:44px; width:100%; }
|
||||
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
|
||||
.load-more-notifications[hidden] { display:none; }
|
||||
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-bulk button { min-height:44px; width:100%; }
|
||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.review-sheet.open { display:flex; }
|
||||
@@ -73,6 +80,21 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.review-file, .review-history { padding:8px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||
.review-file-toggle { min-height:44px; width:100%; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; text-align:left; }
|
||||
.review-file-toggle strong { overflow-wrap:anywhere; }
|
||||
.review-file.reviewed { opacity:.72; }
|
||||
.review-file.reviewed .review-file-toggle { border-color:#22c55e; }
|
||||
.review-mark { min-height:44px; width:100%; margin-top:8px; }
|
||||
.review-mark[aria-pressed="true"] { border-color:#22c55e; background:#123c2a; }
|
||||
.review-note { min-height:88px; margin-top:6px; }
|
||||
.review-feedback { display:grid; gap:8px; }
|
||||
.review-feedback label { display:grid; gap:6px; }
|
||||
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.review-handoff button { min-height:44px; }
|
||||
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
|
||||
.review-handoff-link[hidden] { display:none; }
|
||||
.review-copy-fallback { min-height:140px; }
|
||||
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
|
||||
.review-progress-actions button { min-height:44px; max-width:100%; }
|
||||
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
|
||||
.review-diff-line { display:block; min-width:max-content; }
|
||||
.review-diff-line.hunk { color:#93c5fd; }
|
||||
@@ -80,6 +102,33 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
|
||||
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
|
||||
.review-action { min-height:44px; }
|
||||
.review-retry { min-height:44px; margin-top:10px; }
|
||||
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.update-sheet.open { display:flex; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.update-sheet-header button { min-height:44px; }
|
||||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.update-reply { display:grid; gap:8px; margin-top:16px; }
|
||||
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
|
||||
.update-reply button { min-height:44px; width:100%; }
|
||||
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.issue-sheet.open { display:flex; }
|
||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
@media (max-width: 600px) {
|
||||
header { align-items:flex-start; }
|
||||
.my-work { margin:0; }
|
||||
@@ -87,6 +136,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@@ -117,9 +168,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
||||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
||||
<button id="bulk-mark-read" type="button"></button>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="sidebar">
|
||||
<details class="panel stack" data-panel-key="context" open>
|
||||
@@ -206,6 +264,70 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="issue-sheet" id="issue-sheet" role="dialog" aria-modal="true" aria-labelledby="issue-sheet-title">
|
||||
<section class="issue-sheet-panel">
|
||||
<div class="issue-sheet-header">
|
||||
<div>
|
||||
<div class="small" id="issue-sheet-key"></div>
|
||||
<h3 id="issue-sheet-title">Assigned issue</h3>
|
||||
</div>
|
||||
<button id="close-issue-sheet" type="button">Close sheet</button>
|
||||
</div>
|
||||
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
|
||||
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Retry loading issue</button>
|
||||
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
|
||||
<p class="issue-sheet-content" id="issue-sheet-body"></p>
|
||||
<h2>Recent discussion</h2>
|
||||
<div id="issue-comments"></div>
|
||||
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
|
||||
<h2 id="issue-comment-title">Add comment</h2>
|
||||
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||
<button id="send-issue-comment" type="button">Post comment</button>
|
||||
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="issue-sheet-actions">
|
||||
<button id="close-issue" type="button">Close issue</button>
|
||||
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||||
<section class="update-sheet-panel">
|
||||
<div class="update-sheet-header">
|
||||
<div>
|
||||
<div class="small" id="update-sheet-key"></div>
|
||||
<h3 id="update-sheet-title">Unread update</h3>
|
||||
</div>
|
||||
<button id="keep-update-unread" type="button">Keep unread</button>
|
||||
</div>
|
||||
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
|
||||
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
|
||||
<div class="row">
|
||||
<span class="pill" id="update-subject-type">Update</span>
|
||||
<span class="pill" id="update-subject-state"></span>
|
||||
</div>
|
||||
<h2>Latest comment</h2>
|
||||
<div class="small" id="update-comment-meta"></div>
|
||||
<p class="update-sheet-content" id="update-comment-body"></p>
|
||||
<details>
|
||||
<summary><h2>Subject context</h2></summary>
|
||||
<p class="update-sheet-content muted" id="update-subject-body"></p>
|
||||
</details>
|
||||
<section class="update-reply" aria-labelledby="update-reply-title">
|
||||
<h2 id="update-reply-title">Reply</h2>
|
||||
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
|
||||
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
|
||||
<button id="send-update-reply" type="button">Send reply</button>
|
||||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="update-sheet-actions">
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="review-sheet" id="review-sheet" role="dialog" aria-modal="true" aria-labelledby="review-sheet-title">
|
||||
<section class="review-sheet-panel">
|
||||
<div class="review-sheet-header">
|
||||
@@ -216,10 +338,36 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<button class="review-action" id="close-review-sheet">Close</button>
|
||||
</div>
|
||||
<div id="review-sheet-status" class="small" aria-live="polite">Choose a review request.</div>
|
||||
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
|
||||
<p class="review-sheet-body" id="review-sheet-body"></p>
|
||||
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
|
||||
<h2>Changed files</h2>
|
||||
<div id="review-files" class="muted"></div>
|
||||
<div class="review-progress-actions">
|
||||
<span class="small" id="review-progress" aria-live="polite">0 of 0 files reviewed</span>
|
||||
<button id="next-unreviewed-review" disabled>Next unreviewed</button>
|
||||
</div>
|
||||
<section class="review-feedback" aria-labelledby="review-feedback-title">
|
||||
<h2 id="review-feedback-title">Draft feedback</h2>
|
||||
<label>Intended decision
|
||||
<select id="review-decision">
|
||||
<option value="comment">Comment</option>
|
||||
<option value="approve">Approve</option>
|
||||
<option value="request_changes">Request changes</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Overall summary
|
||||
<textarea id="review-summary" placeholder="Summarize your review"></textarea>
|
||||
</label>
|
||||
<div class="review-handoff">
|
||||
<button id="submit-review">Submit review</button>
|
||||
<span class="small" id="review-submit-status" aria-live="assertive"></span>
|
||||
<button id="copy-review-feedback">Copy feedback & continue in Gitea</button>
|
||||
<span class="small" id="review-handoff-status" aria-live="polite"></span>
|
||||
<textarea class="review-copy-fallback" id="review-copy-fallback" readonly hidden aria-label="Feedback to copy manually"></textarea>
|
||||
<a class="review-handoff-link" id="review-handoff-link" target="_blank" rel="noopener noreferrer" hidden>Continue to Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
<h2>Review history</h2>
|
||||
<div id="review-history" class="muted"></div>
|
||||
</section>
|
||||
@@ -231,6 +379,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/review-sheet.js"></script>
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script>
|
||||
@@ -263,14 +412,28 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
let selectedWorkFilter = 'all';
|
||||
try {
|
||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||
if (['all', 'issue', 'pull', 'review'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||
if (['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||
} catch (e) {
|
||||
console.warn('Could not restore My Work filter', e);
|
||||
}
|
||||
let lastMyWork = [];
|
||||
let lastNotifications = [];
|
||||
let lastContextSnapshot = null;
|
||||
let notificationPagination = { page: 1, total: 0, has_more: false };
|
||||
let hasContextSnapshot = false;
|
||||
let selectedReview = null;
|
||||
let reviewTrigger = null;
|
||||
let selectedUpdate = null;
|
||||
let updateTrigger = null;
|
||||
let selectedIssue = null;
|
||||
let issueTrigger = null;
|
||||
let progress = null;
|
||||
let draft = null;
|
||||
let reviewFiles = [];
|
||||
let selectedReviewHead = '';
|
||||
let bulkConfirmationPending = false;
|
||||
let bulkMarkPending = false;
|
||||
let reviewHandoffPending = false;
|
||||
|
||||
async function fetchReviewJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
@@ -279,20 +442,161 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
return payload;
|
||||
}
|
||||
const reviewController = createReviewController({ fetchJson: fetchReviewJson });
|
||||
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
|
||||
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
|
||||
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
||||
setClock(); setInterval(setClock, 1000);
|
||||
|
||||
async function fetchContextSnapshot() {
|
||||
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
|
||||
async function fetchLiveSnapshot() {
|
||||
const res = await fetch('api/v1/live', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function markNotificationRead(notificationId) {
|
||||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/read', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Mark read failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function markNotificationsRead(ids) {
|
||||
const response = await fetch('api/v1/notifications/read', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Bulk mark read failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function fetchNotificationPage(page) {
|
||||
const response = await fetch('api/v1/notifications?page=' + encodeURIComponent(page), {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Loading unread updates failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function fetchNotificationDetail(notificationId) {
|
||||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId), {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Loading the update failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function postNotificationReply(notificationId, body) {
|
||||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Posting the reply failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
const notificationAcknowledger = createNotificationAcknowledger({
|
||||
markRead: markNotificationRead,
|
||||
onItems: items => {
|
||||
lastMyWork = items;
|
||||
refreshMyWorkView();
|
||||
},
|
||||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
const bulkNotificationAcknowledger = createBulkNotificationAcknowledger({
|
||||
markRead: markNotificationsRead,
|
||||
onItems: items => {
|
||||
lastMyWork = items;
|
||||
refreshMyWorkView();
|
||||
},
|
||||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
const notificationPager = createNotificationPager({
|
||||
load: fetchNotificationPage,
|
||||
onNotifications: items => {
|
||||
lastNotifications = items;
|
||||
if (lastContextSnapshot) {
|
||||
lastContextSnapshot.notifications = lastNotifications;
|
||||
paintMyWork(lastContextSnapshot);
|
||||
}
|
||||
},
|
||||
onPagination: pagination => {
|
||||
notificationPagination = pagination;
|
||||
const loaded = Math.min(pagination.total, pagination.page * 50);
|
||||
qs('#notification-page-status').textContent = pagination.total ?
|
||||
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
|
||||
qs('#load-more-notifications').hidden =
|
||||
selectedWorkFilter !== 'update' || !pagination.has_more;
|
||||
},
|
||||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
const notificationReplier = createNotificationReplier({
|
||||
post: postNotificationReply,
|
||||
storage: localStorage,
|
||||
onStatus: message => { qs('#update-reply-status').textContent = message; },
|
||||
});
|
||||
const notificationReader = createNotificationReader({
|
||||
load: fetchNotificationDetail,
|
||||
markRead: markNotificationRead,
|
||||
onOpen: item => {
|
||||
selectedUpdate = item;
|
||||
qs('#update-sheet').classList.add('open');
|
||||
qs('#update-sheet-key').textContent = item.key || '';
|
||||
qs('#update-sheet-title').textContent = item.title || 'Unread update';
|
||||
qs('#update-comment-meta').textContent = '';
|
||||
qs('#update-comment-body').textContent = '';
|
||||
qs('#update-subject-body').textContent = '';
|
||||
qs('#update-subject-type').textContent = item.subject_type || 'Update';
|
||||
qs('#update-subject-state').textContent = item.state || '';
|
||||
qs('#open-update-gitea').href = item.url || '#';
|
||||
qs('#update-reply').value = notificationReplier.loadDraft(item);
|
||||
qs('#update-reply-status').textContent = '';
|
||||
qs('#send-update-reply').disabled = false;
|
||||
qs('#retry-update-load').hidden = true;
|
||||
qs('#keep-update-unread').focus();
|
||||
},
|
||||
onDetail: detail => {
|
||||
const comment = detail.latest_comment || {};
|
||||
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
|
||||
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
|
||||
qs('#update-subject-state').textContent = detail.state || '';
|
||||
qs('#update-comment-meta').textContent = comment.author ?
|
||||
comment.author + (comment.created_at ? ' · ' + fmt(comment.created_at) : '') :
|
||||
(comment.created_at ? fmt(comment.created_at) : 'No comment author reported');
|
||||
qs('#update-comment-body').textContent = comment.body || 'No comment body was provided.';
|
||||
qs('#update-subject-body').textContent = detail.subject_body || 'No subject context was provided.';
|
||||
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
|
||||
qs('#retry-update-load').hidden = true;
|
||||
},
|
||||
onItems: items => {
|
||||
const readId = selectedUpdate?.notification_id;
|
||||
lastMyWork = items;
|
||||
if (Number.isInteger(readId)) {
|
||||
lastNotifications = lastNotifications.filter(item => item.id !== readId);
|
||||
}
|
||||
refreshMyWorkView();
|
||||
},
|
||||
onStatus: message => {
|
||||
qs('#update-sheet-status').textContent = message;
|
||||
qs('#retry-update-load').hidden = !message.startsWith('Could not load update.');
|
||||
if (message === 'Inbox cleared.') qs('#my-work-action-status').textContent = message;
|
||||
},
|
||||
onClose: () => closeUpdateSheet(false),
|
||||
});
|
||||
|
||||
function renderContextSnapshot(data) {
|
||||
liveMode = true;
|
||||
hasContextSnapshot = true;
|
||||
lastContextSnapshot = data;
|
||||
if (data.error && lastMyWork.length) markMyWorkStale();
|
||||
else paintMyWork(data);
|
||||
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
|
||||
@@ -334,6 +638,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
|
||||
function paintMyWork(data) {
|
||||
lastMyWork = buildMyWork(data);
|
||||
refreshMyWorkView();
|
||||
}
|
||||
|
||||
function refreshMyWorkView() {
|
||||
const counts = countMyWork(lastMyWork);
|
||||
Object.entries(counts).forEach(([filter, count]) => {
|
||||
const element = qs('[data-work-count="' + filter + '"]');
|
||||
@@ -341,27 +649,161 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
});
|
||||
qs('#my-work').removeAttribute('data-stale');
|
||||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||||
summarizeMyWork(lastMyWork) : 'No assigned work or review requests.';
|
||||
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
|
||||
renderMyWork();
|
||||
}
|
||||
|
||||
function renderMyWork() {
|
||||
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
|
||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||||
const contents =
|
||||
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : 'Issue') + '</span>' +
|
||||
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
|
||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
|
||||
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
|
||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||||
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
|
||||
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
||||
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
||||
'<button class="read-update" data-update-index="' + index + '">Read update</button>' : '';
|
||||
if (item.is_review) {
|
||||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||||
return '<button class="my-work-card review-trigger" data-review-index="' + index + '">' + contents + '</button>';
|
||||
return '<article class="my-work-card"><button class="my-work-card-main review-trigger" data-review-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
|
||||
}
|
||||
return '<a class="my-work-card" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>';
|
||||
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')) + '.</div>';
|
||||
if (item.kind === 'issue') {
|
||||
return '<article class="my-work-card"><button class="my-work-card-main issue-trigger" data-issue-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
|
||||
}
|
||||
return '<article class="my-work-card"><a class="my-work-card-main" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>' + readUpdate + markRead + '</article>';
|
||||
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.</div>';
|
||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button));
|
||||
});
|
||||
document.querySelectorAll('[data-issue-index]').forEach(button => {
|
||||
button.addEventListener('click', () => openIssueSheet(lastMyWork[Number(button.dataset.issueIndex)], button));
|
||||
});
|
||||
document.querySelectorAll('[data-update-index]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = lastMyWork[Number(button.dataset.updateIndex)];
|
||||
if (!item) return;
|
||||
updateTrigger = button;
|
||||
notificationReader.open(item, lastMyWork);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-notification-id]').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const notificationId = Number(button.dataset.notificationId);
|
||||
button.disabled = true;
|
||||
const acknowledged = await notificationAcknowledger.acknowledge(lastMyWork, notificationId);
|
||||
if (acknowledged) {
|
||||
lastNotifications = lastNotifications.filter(item => item.id !== notificationId);
|
||||
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
|
||||
} else {
|
||||
document.querySelector('[data-notification-id="' + notificationId + '"]')?.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
const allIds = notificationIds(visible);
|
||||
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
|
||||
const bulkBar = qs('#bulk-mark-read-bar');
|
||||
const bulkButton = qs('#bulk-mark-read');
|
||||
bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0;
|
||||
qs('#load-more-notifications').hidden =
|
||||
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
|
||||
bulkButton.disabled = bulkMarkPending;
|
||||
const bulkLabel = allIds.length > ids.length ?
|
||||
'next ' + ids.length + ' of ' + allIds.length + ' loaded updates' :
|
||||
'all ' + ids.length + ' updates';
|
||||
bulkButton.textContent = bulkConfirmationPending ?
|
||||
'Confirm marking ' + bulkLabel + ' read' :
|
||||
'Mark ' + bulkLabel + ' read';
|
||||
}
|
||||
|
||||
function reviewFileElement(filename) {
|
||||
return Array.from(document.querySelectorAll('.review-file')).find(
|
||||
element => element.dataset.reviewFilename === filename
|
||||
);
|
||||
}
|
||||
|
||||
function showReviewProgress(snapshot) {
|
||||
const complete = snapshot.total > 0 && snapshot.reviewedCount === snapshot.total;
|
||||
qs('#review-progress').textContent = complete ?
|
||||
'All files reviewed — finish in Gitea' :
|
||||
(snapshot.newHead ? 'New commits detected · ' : '') + snapshot.reviewedCount + ' of ' + snapshot.total + ' files reviewed';
|
||||
qs('#next-unreviewed-review').disabled = !snapshot.nextFilename;
|
||||
document.querySelectorAll('.review-mark').forEach(button => {
|
||||
const reviewed = snapshot.reviewed.includes(button.dataset.reviewFilename);
|
||||
button.setAttribute('aria-pressed', String(reviewed));
|
||||
button.textContent = reviewed ? 'Reviewed' : 'Mark reviewed';
|
||||
button.closest('.review-file')?.classList.toggle('reviewed', reviewed);
|
||||
});
|
||||
}
|
||||
|
||||
function openNextUnreviewed(snapshot) {
|
||||
if (!snapshot?.nextFilename) return;
|
||||
const file = reviewFileElement(snapshot.nextFilename);
|
||||
if (!file) return;
|
||||
const toggle = file.querySelector('.review-file-toggle');
|
||||
const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
|
||||
if (toggle && panel && toggle.getAttribute('aria-expanded') !== 'true') {
|
||||
createReviewController.toggleDiff(toggle, panel);
|
||||
}
|
||||
file.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
toggle?.focus();
|
||||
}
|
||||
|
||||
function renderIssueComment(comment) {
|
||||
return '<div class="issue-comment"><div class="small">' +
|
||||
escapeHtml(comment.author || 'Unknown author') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
|
||||
'</div><div class="issue-sheet-content">' +
|
||||
escapeHtml(comment.body || 'No comment body provided.') + '</div></div>';
|
||||
}
|
||||
|
||||
async function openIssueSheet(item, trigger) {
|
||||
if (!item) return;
|
||||
selectedIssue = item;
|
||||
issueTrigger = trigger;
|
||||
qs('#issue-sheet').classList.add('open');
|
||||
qs('#issue-sheet-key').textContent = item.key || '';
|
||||
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
|
||||
qs('#issue-sheet-status').textContent = 'Loading issue…';
|
||||
qs('#issue-sheet-body').textContent = '';
|
||||
qs('#issue-labels').textContent = '';
|
||||
qs('#issue-assignees').textContent = '';
|
||||
qs('#issue-comments').textContent = '';
|
||||
qs('#issue-comment').value = issueController.loadDraft(item);
|
||||
qs('#issue-comment-status').textContent = '';
|
||||
qs('#retry-issue-load').hidden = true;
|
||||
qs('#open-issue-gitea').href = item.url || '#';
|
||||
qs('#send-issue-comment').disabled = false;
|
||||
qs('#close-issue').disabled = false;
|
||||
qs('#close-issue-sheet').focus();
|
||||
try {
|
||||
const detail = await issueController.load(item);
|
||||
if (selectedIssue !== item) return;
|
||||
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
|
||||
qs('#issue-sheet-body').textContent = detail.body || 'No description provided.';
|
||||
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
|
||||
'<span class="pill">' + escapeHtml(label) + '</span>'
|
||||
).join(' ');
|
||||
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
|
||||
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
|
||||
qs('#issue-comments').innerHTML = (detail.comments || []).length ?
|
||||
detail.comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
|
||||
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
||||
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
|
||||
} catch (error) {
|
||||
if (selectedIssue !== item) return;
|
||||
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
|
||||
qs('#retry-issue-load').hidden = false;
|
||||
qs('#retry-issue-load').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeIssueSheet() {
|
||||
qs('#issue-sheet').classList.remove('open');
|
||||
selectedIssue = null;
|
||||
if (issueTrigger?.isConnected) issueTrigger.focus();
|
||||
}
|
||||
|
||||
async function openReviewSheet(item, trigger) {
|
||||
@@ -371,10 +813,25 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#review-sheet-key').textContent = item.key;
|
||||
qs('#review-sheet-title').textContent = item.title;
|
||||
qs('#review-sheet-status').textContent = 'Loading review details…';
|
||||
qs('#retry-review-load').hidden = true;
|
||||
qs('#review-sheet-body').textContent = '';
|
||||
qs('#review-files').textContent = '';
|
||||
qs('#review-progress').textContent = '0 of 0 files reviewed';
|
||||
qs('#next-unreviewed-review').disabled = true;
|
||||
qs('#review-history').textContent = '';
|
||||
qs('#open-review-gitea').href = item.url;
|
||||
qs('#review-decision').value = 'comment';
|
||||
qs('#review-summary').value = '';
|
||||
qs('#review-copy-fallback').hidden = true;
|
||||
qs('#review-copy-fallback').value = '';
|
||||
qs('#review-handoff-link').hidden = true;
|
||||
qs('#review-handoff-link').href = item.url;
|
||||
qs('#review-handoff-status').textContent = '';
|
||||
qs('#review-submit-status').textContent = '';
|
||||
qs('#submit-review').disabled = true;
|
||||
draft = null;
|
||||
reviewFiles = [];
|
||||
selectedReviewHead = '';
|
||||
qs('#close-review-sheet').focus();
|
||||
try {
|
||||
const detail = await reviewController.load(selectedReview);
|
||||
@@ -390,28 +847,83 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
if (panel) createReviewController.toggleDiff(button, panel);
|
||||
});
|
||||
});
|
||||
progress = createReviewController.createProgress({
|
||||
storage: localStorage,
|
||||
repository: item.repository,
|
||||
number: item.number,
|
||||
headSha: detail.head_sha || 'unknown',
|
||||
files: detail.files || [],
|
||||
});
|
||||
reviewFiles = detail.files || [];
|
||||
selectedReviewHead = detail.head_sha || '';
|
||||
draft = createReviewController.createDraft({
|
||||
storage: localStorage,
|
||||
repository: item.repository,
|
||||
number: item.number,
|
||||
headSha: detail.head_sha || 'unknown',
|
||||
files: reviewFiles,
|
||||
});
|
||||
const draftSnapshot = draft.snapshot();
|
||||
qs('#review-decision').value = draftSnapshot.decision;
|
||||
qs('#review-summary').value = draftSnapshot.summary;
|
||||
document.querySelectorAll('.review-note').forEach(note => {
|
||||
note.value = draftSnapshot.notes[note.dataset.reviewFilename] || '';
|
||||
note.addEventListener('input', () => draft.setNote(note.dataset.reviewFilename, note.value));
|
||||
});
|
||||
document.querySelectorAll('.review-mark').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const snapshot = progress.markReviewed(button.dataset.reviewFilename);
|
||||
showReviewProgress(snapshot);
|
||||
openNextUnreviewed(snapshot);
|
||||
});
|
||||
});
|
||||
const progressSnapshot = progress.snapshot();
|
||||
showReviewProgress(progressSnapshot);
|
||||
openNextUnreviewed(progressSnapshot);
|
||||
qs('#review-history').innerHTML = (detail.reviews || []).length ? detail.reviews.map(review =>
|
||||
'<div class="review-history"><strong>' + escapeHtml(review.user?.login || 'Reviewer') + '</strong> · ' +
|
||||
escapeHtml(review.state || 'commented') + (review.body ? '<div class="small">' + escapeHtml(review.body) + '</div>' : '') + '</div>'
|
||||
).join('') : '<div>No prior reviews.</div>';
|
||||
qs('#review-sheet-status').textContent = 'Ready to review · by ' + (detail.author || 'unknown author');
|
||||
qs('#submit-review').disabled = false;
|
||||
} catch (error) {
|
||||
qs('#review-sheet-status').textContent = error.message + ' Use Open in Gitea or close and retry.';
|
||||
if (selectedReview !== item) return;
|
||||
qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.';
|
||||
qs('#retry-review-load').hidden = false;
|
||||
qs('#retry-review-load').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeReviewSheet() {
|
||||
qs('#review-sheet').classList.remove('open');
|
||||
selectedReview = null;
|
||||
progress = null;
|
||||
draft = null;
|
||||
reviewFiles = [];
|
||||
selectedReviewHead = '';
|
||||
if (reviewTrigger?.isConnected) reviewTrigger.focus();
|
||||
}
|
||||
|
||||
function closeUpdateSheet(restoreTrigger = true) {
|
||||
qs('#update-sheet').classList.remove('open');
|
||||
selectedUpdate = null;
|
||||
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
||||
else qs('[data-work-filter="update"]')?.focus();
|
||||
}
|
||||
|
||||
function markMyWorkStale() {
|
||||
qs('#my-work').setAttribute('data-stale', 'true');
|
||||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||||
'Update failed · showing last known work' : 'Work inbox unavailable.';
|
||||
}
|
||||
|
||||
function markNotificationsStale() {
|
||||
qs('#my-work').setAttribute('data-stale', 'true');
|
||||
qs('#my-work-status').textContent = lastNotifications.length ?
|
||||
'Unread updates unavailable · showing last known updates' :
|
||||
'Unread updates unavailable · assigned work is fresh';
|
||||
}
|
||||
|
||||
function paintDeltas(deltas) {
|
||||
const el = qs('#ai');
|
||||
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
|
||||
@@ -431,16 +943,44 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#gitea-events-status').textContent = message;
|
||||
}
|
||||
|
||||
async function loadEventStream() {
|
||||
setEventStreamStatus('Updating…');
|
||||
try {
|
||||
const res = await fetch('api/v1/events', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
paintEventStream(await res.json());
|
||||
function renderLiveSnapshot(snapshot) {
|
||||
const notificationsFresh = Array.isArray(snapshot.notifications);
|
||||
if (notificationsFresh) {
|
||||
if (notificationPagination.page > 1) {
|
||||
const byId = new Map(lastNotifications.map(item => [item.id, item]));
|
||||
snapshot.notifications.forEach(item => byId.set(item.id, item));
|
||||
lastNotifications = Array.from(byId.values());
|
||||
} else {
|
||||
lastNotifications = snapshot.notifications;
|
||||
}
|
||||
if (snapshot.notification_pagination) {
|
||||
const page = notificationPagination.page > 1 ? notificationPagination.page :
|
||||
snapshot.notification_pagination.page;
|
||||
notificationPager.reset({
|
||||
page,
|
||||
total: snapshot.notification_pagination.total,
|
||||
has_more: page * 50 < snapshot.notification_pagination.total,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (snapshot.context) {
|
||||
snapshot.context.notifications = lastNotifications;
|
||||
renderContextSnapshot(snapshot.context);
|
||||
if (!notificationsFresh) markNotificationsStale();
|
||||
} else handleContextError(new Error('Context section unavailable'));
|
||||
if (snapshot.events) {
|
||||
paintEventStream(snapshot.events);
|
||||
setEventStreamStatus('Updated ' + fmt(new Date()));
|
||||
} catch (e) {
|
||||
} else {
|
||||
setEventStreamStatus('Update failed · showing last activity');
|
||||
}
|
||||
if (snapshot.freshness?.degraded && !snapshot.freshness.revalidating) {
|
||||
const retrySeconds = Number(snapshot.freshness.retry_in_seconds) || 0;
|
||||
setEventStreamStatus('Refresh failed · showing last known data' +
|
||||
(retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : ''));
|
||||
} else if (snapshot.freshness?.revalidating) {
|
||||
setEventStreamStatus('Refreshing · showing recent snapshot');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -510,20 +1050,225 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
|
||||
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
|
||||
document.addEventListener('keydown', (e) => { if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); qs('#cmd-palette').classList.toggle('open'); if(qs('#cmd-palette').classList.contains('open')){ qs('#cmd-input').focus(); renderCommands(''); } } });
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && selectedIssue) {
|
||||
e.preventDefault();
|
||||
closeIssueSheet();
|
||||
return;
|
||||
}
|
||||
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
|
||||
e.preventDefault();
|
||||
qs('#cmd-palette').classList.toggle('open');
|
||||
if (qs('#cmd-palette').classList.contains('open')) {
|
||||
qs('#cmd-input').focus();
|
||||
renderCommands('');
|
||||
}
|
||||
}
|
||||
});
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
qs('#close-issue-sheet').addEventListener('click', closeIssueSheet);
|
||||
qs('#retry-issue-load').addEventListener('click', () => {
|
||||
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
||||
});
|
||||
qs('#issue-comment').addEventListener('input', event => {
|
||||
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
|
||||
});
|
||||
qs('#send-issue-comment').addEventListener('click', async () => {
|
||||
if (!selectedIssue) return;
|
||||
const body = qs('#issue-comment').value.trim();
|
||||
if (!body) {
|
||||
qs('#issue-comment-status').textContent = 'Write a comment before posting.';
|
||||
qs('#issue-comment').focus();
|
||||
return;
|
||||
}
|
||||
const button = qs('#send-issue-comment');
|
||||
button.disabled = true;
|
||||
qs('#issue-comment-status').textContent = 'Posting comment…';
|
||||
try {
|
||||
const comment = await issueController.comment(selectedIssue, body);
|
||||
const empty = qs('#issue-comments .muted');
|
||||
if (empty) empty.remove();
|
||||
qs('#issue-comments').insertAdjacentHTML('beforeend', renderIssueComment(comment));
|
||||
qs('#issue-comment').value = '';
|
||||
qs('#issue-comment-status').textContent = 'Comment posted.';
|
||||
} catch (error) {
|
||||
qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
qs('#issue-comment').focus();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#close-issue').addEventListener('click', async () => {
|
||||
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
|
||||
const closing = selectedIssue;
|
||||
const button = qs('#close-issue');
|
||||
button.disabled = true;
|
||||
qs('#issue-sheet-status').textContent = 'Closing issue…';
|
||||
try {
|
||||
await issueController.close(selectedIssue);
|
||||
lastMyWork = lastMyWork.filter(item =>
|
||||
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number)
|
||||
);
|
||||
closeIssueSheet();
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = closing.key + ' closed.';
|
||||
} catch (error) {
|
||||
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
|
||||
qs('#retry-update-load').addEventListener('click', () => {
|
||||
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
|
||||
});
|
||||
qs('#update-reply').addEventListener('input', event => {
|
||||
if (selectedUpdate) notificationReplier.saveDraft(selectedUpdate, event.target.value);
|
||||
});
|
||||
qs('#send-update-reply').addEventListener('click', async () => {
|
||||
if (!selectedUpdate) return;
|
||||
const body = qs('#update-reply').value.trim();
|
||||
if (!body) {
|
||||
qs('#update-reply-status').textContent = 'Write a reply before sending.';
|
||||
qs('#update-reply').focus();
|
||||
return;
|
||||
}
|
||||
qs('#send-update-reply').disabled = true;
|
||||
const result = await notificationReplier.submit(selectedUpdate, body);
|
||||
qs('#send-update-reply').disabled = false;
|
||||
if (result) {
|
||||
qs('#update-reply').value = '';
|
||||
qs('#mark-update-read-next').focus();
|
||||
} else {
|
||||
qs('#update-reply').focus();
|
||||
}
|
||||
});
|
||||
qs('#mark-update-read-next').addEventListener('click', async () => {
|
||||
qs('#mark-update-read-next').disabled = true;
|
||||
try {
|
||||
await notificationReader.markReadAndNext(lastMyWork);
|
||||
} finally {
|
||||
qs('#mark-update-read-next').disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
|
||||
qs('#retry-review-load').addEventListener('click', () => {
|
||||
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
|
||||
});
|
||||
qs('#next-unreviewed-review').addEventListener('click', () => {
|
||||
if (progress) openNextUnreviewed(progress.snapshot());
|
||||
});
|
||||
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
|
||||
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
|
||||
qs('#submit-review').addEventListener('click', async () => {
|
||||
if (!draft || !selectedReview || !selectedReviewHead) return;
|
||||
const snapshot = draft.snapshot();
|
||||
const labels = { approve: 'Approve', request_changes: 'Request changes' };
|
||||
if (labels[snapshot.decision] && !window.confirm(
|
||||
labels[snapshot.decision] + ' ' + selectedReview.repository + '#' + selectedReview.number + '?'
|
||||
)) return;
|
||||
const button = qs('#submit-review');
|
||||
button.disabled = true;
|
||||
qs('#review-submit-status').textContent = 'Submitting review…';
|
||||
try {
|
||||
const result = await reviewController.submit(selectedReview, {
|
||||
decision: snapshot.decision,
|
||||
body: createReviewController.formatFeedback(snapshot, reviewFiles),
|
||||
expected_head_sha: selectedReviewHead,
|
||||
});
|
||||
draft.clear();
|
||||
progress?.clear();
|
||||
qs('#review-decision').value = 'comment';
|
||||
qs('#review-summary').value = '';
|
||||
document.querySelectorAll('.review-note').forEach(note => { note.value = ''; });
|
||||
if (progress) showReviewProgress(progress.snapshot());
|
||||
qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.';
|
||||
await load();
|
||||
button.focus();
|
||||
} catch (error) {
|
||||
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#copy-review-feedback').addEventListener('click', async () => {
|
||||
if (!draft || !selectedReview || reviewHandoffPending) return;
|
||||
reviewHandoffPending = true;
|
||||
qs('#copy-review-feedback').disabled = true;
|
||||
const fallback = qs('#review-copy-fallback');
|
||||
const directLink = qs('#review-handoff-link');
|
||||
fallback.hidden = true;
|
||||
directLink.hidden = true;
|
||||
let handoffWindow = null;
|
||||
try {
|
||||
const result = await createReviewController.copyAndContinue({
|
||||
text: createReviewController.formatFeedback(draft.snapshot(), reviewFiles),
|
||||
url: selectedReview.url,
|
||||
copy: text => navigator.clipboard.writeText(text),
|
||||
open: () => {
|
||||
handoffWindow = window.open('about:blank', '_blank');
|
||||
if (handoffWindow) handoffWindow.opener = null;
|
||||
return handoffWindow;
|
||||
},
|
||||
fallback: text => {
|
||||
fallback.value = text;
|
||||
fallback.hidden = false;
|
||||
fallback.focus();
|
||||
fallback.select();
|
||||
},
|
||||
});
|
||||
if (result.opened) {
|
||||
qs('#review-handoff-status').textContent = 'Feedback copied. Gitea opened in a new tab.';
|
||||
} else {
|
||||
directLink.hidden = false;
|
||||
qs('#review-handoff-status').textContent = result.copied ?
|
||||
'Feedback copied. Your browser blocked the new tab; continue with the link below.' :
|
||||
'Clipboard unavailable. Copy the selected feedback, then continue to Gitea.';
|
||||
}
|
||||
} finally {
|
||||
reviewHandoffPending = false;
|
||||
qs('#copy-review-feedback').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const contextPoller = createContextPoller({
|
||||
fetchContext: fetchContextSnapshot,
|
||||
onSnapshot: renderContextSnapshot,
|
||||
onError: handleContextError,
|
||||
fetchContext: fetchLiveSnapshot,
|
||||
onSnapshot: renderLiveSnapshot,
|
||||
onError: error => {
|
||||
handleContextError(error);
|
||||
setEventStreamStatus('Update failed · showing last activity');
|
||||
},
|
||||
isHidden: () => document.hidden,
|
||||
intervalMs: 8000,
|
||||
});
|
||||
function load() { return contextPoller.refresh(); }
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
qs('#load-more-notifications').addEventListener('click', () =>
|
||||
notificationPager.loadMore(lastNotifications)
|
||||
);
|
||||
qs('#bulk-mark-read').addEventListener('click', async () => {
|
||||
const allIds = notificationIds(filterMyWork(lastMyWork, 'update'));
|
||||
const ids = allIds.slice(0, 50);
|
||||
if (!ids.length || bulkMarkPending) return;
|
||||
if (!bulkConfirmationPending) {
|
||||
bulkConfirmationPending = true;
|
||||
qs('#my-work-action-status').textContent = 'Confirm to mark all visible updates read.';
|
||||
renderMyWork();
|
||||
return;
|
||||
}
|
||||
bulkConfirmationPending = false;
|
||||
bulkMarkPending = true;
|
||||
renderMyWork();
|
||||
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, ids);
|
||||
if (result) {
|
||||
const marked = new Set(result.marked);
|
||||
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
|
||||
}
|
||||
bulkMarkPending = false;
|
||||
renderMyWork();
|
||||
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
|
||||
});
|
||||
document.querySelectorAll('[data-work-filter]').forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
|
||||
button.addEventListener('click', () => {
|
||||
@@ -540,11 +1285,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
});
|
||||
});
|
||||
contextPoller.start();
|
||||
loadEventStream();
|
||||
setInterval(() => { if (!document.hidden) loadEventStream(); }, 5000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
contextPoller.setVisible(!document.hidden);
|
||||
if (!document.hidden) loadEventStream();
|
||||
});
|
||||
|
||||
/* Widgets */
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
function createIssueSheet({ fetchJson, storage }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
load(item) {
|
||||
return fetchJson(issuePath(item) + '/detail', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
},
|
||||
saveDraft(item, body) {
|
||||
try { storage?.setItem(draftKey(item), body); }
|
||||
catch (_error) { /* The textarea remains the fallback. */ }
|
||||
},
|
||||
close(item) {
|
||||
if (closeRequest) return closeRequest;
|
||||
closeRequest = fetchJson(issuePath(item) + '/close', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
return result;
|
||||
}).finally(() => { closeRequest = null; });
|
||||
return closeRequest;
|
||||
},
|
||||
comment(item, body) {
|
||||
if (commentRequest) return commentRequest;
|
||||
this.saveDraft(item, body);
|
||||
commentRequest = fetchJson(issuePath(item) + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body }),
|
||||
}).then(result => {
|
||||
try { storage?.removeItem(draftKey(item)); }
|
||||
catch (_error) { /* The upstream comment is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { commentRequest = null; });
|
||||
return commentRequest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
|
||||
+247
-4
@@ -4,7 +4,7 @@ function buildMyWork(data) {
|
||||
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
|
||||
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||
|
||||
return issues.concat(pulls).map((item) => {
|
||||
const work = issues.concat(pulls).map((item) => {
|
||||
const labels = item.labels || [];
|
||||
const priorityLabel = labels.find((label) =>
|
||||
priorityLabels.includes(String(label).toLowerCase())
|
||||
@@ -16,29 +16,264 @@ function buildMyWork(data) {
|
||||
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 ? 1 : (assigned ? 2 : 3)),
|
||||
_priority: priorityLabel ? 0 : (isReview ? 2 : (assigned ? 3 : 4)),
|
||||
};
|
||||
}).sort((left, right) =>
|
||||
});
|
||||
|
||||
const byKey = new Map(work.map((item) => [item.kind + ':' + item.key, item]));
|
||||
(data.notifications || []).filter((item) => item && item.unread).forEach((update) => {
|
||||
const key = (update.repository || 'unknown') + '#' + update.number;
|
||||
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
|
||||
const existing = byKey.get(subjectKind + ':' + key);
|
||||
if (existing) {
|
||||
existing.has_update = true;
|
||||
existing.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 createNotificationReplier({ post, storage, onStatus }) {
|
||||
let pending = false;
|
||||
const keyFor = item => 'stackchain.update-reply.v1.' + item.notification_id;
|
||||
return {
|
||||
loadDraft(item) {
|
||||
try { return storage.getItem(keyFor(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
},
|
||||
saveDraft(item, body) {
|
||||
try { storage.setItem(keyFor(item), body); }
|
||||
catch (_error) { /* Keep the editable textarea as the fallback. */ }
|
||||
},
|
||||
async submit(item, body) {
|
||||
if (pending) return false;
|
||||
pending = true;
|
||||
this.saveDraft(item, body);
|
||||
onStatus('Sending reply…');
|
||||
try {
|
||||
const result = await post(item.notification_id, body);
|
||||
try { storage.removeItem(keyFor(item)); }
|
||||
catch (_error) { /* The posted reply is still authoritative. */ }
|
||||
onStatus('Reply posted. You can mark this update read when ready.');
|
||||
return result;
|
||||
} catch (_error) {
|
||||
onStatus('Could not send reply. Your draft is safe; retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = 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 reviewLabel + ' · ' + assignedLabel;
|
||||
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
|
||||
}
|
||||
|
||||
function countMyWork(items) {
|
||||
@@ -47,6 +282,7 @@ function countMyWork(items) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,5 +290,12 @@ 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;
|
||||
buildMyWork.createNotificationReplier = createNotificationReplier;
|
||||
module.exports = buildMyWork;
|
||||
}
|
||||
+165
-4
@@ -1,4 +1,6 @@
|
||||
function createReviewController({ fetchJson }) {
|
||||
let pendingSubmission = null;
|
||||
|
||||
function endpoint(item) {
|
||||
const [owner, repo] = String(item.repository || '').split('/');
|
||||
if (!owner || !repo || !Number.isInteger(Number(item.number))) {
|
||||
@@ -12,7 +14,17 @@ function createReviewController({ fetchJson }) {
|
||||
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
|
||||
}
|
||||
|
||||
return { load };
|
||||
function submit(item, payload) {
|
||||
if (pendingSubmission) return pendingSubmission;
|
||||
pendingSubmission = fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).finally(() => { pendingSubmission = null; });
|
||||
return pendingSubmission;
|
||||
}
|
||||
|
||||
return { load, submit };
|
||||
}
|
||||
|
||||
function diffLineClass(line) {
|
||||
@@ -38,10 +50,14 @@ function renderDiffFile(file, index, escapeHtml) {
|
||||
preview = '<div class="review-diff-empty" id="' + panelId + '" hidden>' + message +
|
||||
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
|
||||
}
|
||||
return '<div class="review-file"><button class="review-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
|
||||
'<strong>' + escapeHtml(file.filename || 'Unknown file') + '</strong><span class="small">' +
|
||||
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 + '</div>';
|
||||
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) {
|
||||
@@ -50,8 +66,153 @@ function toggleDiff(button, panel) {
|
||||
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();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
reviewed = [];
|
||||
newHead = false;
|
||||
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, markReviewed, clear };
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
draft = { notes: {}, summary: '', decision: 'comment' };
|
||||
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, setNote, setSummary, setDecision, clear };
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
+388
-32
@@ -1,6 +1,9 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -8,6 +11,11 @@ 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
|
||||
|
||||
|
||||
class StaleReviewError(ValueError):
|
||||
"""Raised before mutation when a pull request head changed during review."""
|
||||
|
||||
|
||||
def _auth() -> dict[str, str]:
|
||||
@@ -17,30 +25,47 @@ 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 httpx.AsyncClient(base_url=GITEA_URL, timeout=10) as client:
|
||||
async with 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)
|
||||
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
|
||||
|
||||
|
||||
@@ -98,12 +123,300 @@ async def issues() -> list[dict]:
|
||||
)
|
||||
|
||||
|
||||
async def pull_requests() -> list[dict]:
|
||||
assigned = await fetch(
|
||||
"repos/issues/search?state=open&assigned=true&type=pulls&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(),
|
||||
)
|
||||
review_requested = await fetch(
|
||||
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50"
|
||||
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 reply_to_notification(thread_id: int, body: str) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
raise ValueError("Gitea notification thread response was not an object")
|
||||
repository = thread.get("repository")
|
||||
subject = thread.get("subject")
|
||||
if not isinstance(repository, dict) or not isinstance(subject, dict):
|
||||
raise ValueError("Notification does not identify a conversation")
|
||||
repository_name = repository.get("full_name")
|
||||
subject_path = _gitea_api_path(subject.get("url"))
|
||||
match = re.fullmatch(
|
||||
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
|
||||
)
|
||||
if (
|
||||
not match
|
||||
or match.group(1) != repository_name
|
||||
or subject.get("type") not in {"Issue", "Pull"}
|
||||
):
|
||||
raise ValueError("Notification subject is not a supported conversation")
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{match.group(1)}/issues/{match.group(3)}/comments",
|
||||
headers=_auth(),
|
||||
json={"body": body},
|
||||
)
|
||||
response.raise_for_status()
|
||||
comment = response.json()
|
||||
if not isinstance(comment, dict):
|
||||
raise ValueError("Gitea comment response was not an object")
|
||||
return {
|
||||
"id": comment.get("id"),
|
||||
"url": _safe_web_url(comment.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
async def close_issue(repository: str, number: int) -> dict:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
headers=_auth(),
|
||||
json={"state": "closed"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
issue = response.json()
|
||||
if not isinstance(issue, dict) or issue.get("state") != "closed":
|
||||
raise ValueError("Gitea did not confirm issue closure")
|
||||
return {
|
||||
"number": issue.get("number"),
|
||||
"state": "closed",
|
||||
"closed_at": issue.get("closed_at", "")
|
||||
if isinstance(issue.get("closed_at"), str)
|
||||
else "",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_issue_comment(comment: dict) -> dict:
|
||||
user_value = comment.get("user")
|
||||
user: dict = user_value if isinstance(user_value, dict) else {}
|
||||
return {
|
||||
"id": comment.get("id"),
|
||||
"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": _safe_web_url(comment.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
async def comment_on_issue(repository: str, number: int, body: str) -> dict:
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/comments",
|
||||
headers=_auth(),
|
||||
json={"body": body},
|
||||
)
|
||||
response.raise_for_status()
|
||||
comment = response.json()
|
||||
if not isinstance(comment, dict):
|
||||
raise ValueError("Gitea comment response was not an object")
|
||||
return _normalize_issue_comment(comment)
|
||||
|
||||
|
||||
async def issue_detail(repository: str, number: int) -> dict:
|
||||
base = f"repos/{repository}/issues/{number}"
|
||||
issue, comments = await asyncio.gather(
|
||||
fetch(base), fetch(f"{base}/comments?limit=20&page=1")
|
||||
)
|
||||
if not isinstance(issue, dict):
|
||||
raise ValueError("Gitea issue response was not an object")
|
||||
labels_value = issue.get("labels")
|
||||
labels: list = labels_value if isinstance(labels_value, list) else []
|
||||
assignees_value = issue.get("assignees")
|
||||
assignees: list = assignees_value if isinstance(assignees_value, list) else []
|
||||
comments_value: list = comments if isinstance(comments, list) else []
|
||||
normalized_comments = [
|
||||
_normalize_issue_comment(comment)
|
||||
for comment in comments_value
|
||||
if isinstance(comment, dict)
|
||||
]
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
|
||||
"state": issue.get("state", "") if isinstance(issue.get("state"), str) else "",
|
||||
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
"labels": [
|
||||
label["name"] for label in labels
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||
],
|
||||
"assignees": [
|
||||
assignee["login"] for assignee in assignees
|
||||
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
|
||||
],
|
||||
"comments": normalized_comments,
|
||||
}
|
||||
|
||||
|
||||
async def is_assigned_issue(repository: str, number: int) -> bool:
|
||||
assigned = await issues()
|
||||
return any(
|
||||
isinstance(issue, dict)
|
||||
and issue.get("number") == number
|
||||
and isinstance(issue.get("repository"), dict)
|
||||
and issue["repository"].get("full_name") == repository
|
||||
for issue in (assigned or [])
|
||||
)
|
||||
|
||||
|
||||
async def pull_requests() -> list[dict]:
|
||||
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 (
|
||||
@@ -119,14 +432,15 @@ async def pull_requests() -> list[dict]:
|
||||
|
||||
|
||||
async def is_requested_review(repository: str, number: int) -> bool:
|
||||
pulls = await pull_requests()
|
||||
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 "review_requested" in (pull.get("work_reasons") or [])
|
||||
and isinstance(pull.get("repository"), dict)
|
||||
and pull["repository"].get("full_name") == repository
|
||||
for pull in pulls
|
||||
for pull in (pulls or [])
|
||||
)
|
||||
|
||||
|
||||
@@ -135,16 +449,19 @@ async def pull_review_detail(repository: str, number: int) -> dict:
|
||||
pull = await fetch(base)
|
||||
if not isinstance(pull, dict):
|
||||
raise ValueError("Gitea pull request response was not an object")
|
||||
files = await fetch(f"{base}/files")
|
||||
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 ""
|
||||
status = await fetch(f"repos/{repository}/commits/{sha}/status")
|
||||
reviews = await fetch(f"{base}/reviews")
|
||||
diff, diff_truncated = await fetch_text(
|
||||
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
|
||||
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 {}
|
||||
@@ -189,14 +506,53 @@ async def pull_review_detail(repository: str, number: int) -> dict:
|
||||
"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() -> list[dict]:
|
||||
user = await current_user()
|
||||
async def submit_pull_review(
|
||||
repository: str,
|
||||
number: int,
|
||||
expected_head_sha: str,
|
||||
decision: str,
|
||||
body: str,
|
||||
) -> dict:
|
||||
base = f"repos/{repository}/pulls/{number}"
|
||||
pull = await fetch(base)
|
||||
head = pull.get("head") if isinstance(pull, dict) else None
|
||||
current_sha = head.get("sha") if isinstance(head, dict) else None
|
||||
if current_sha != expected_head_sha:
|
||||
raise StaleReviewError("Pull request changed while it was being reviewed")
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/{base}/reviews",
|
||||
headers=_auth(),
|
||||
json={
|
||||
"body": body,
|
||||
"event": {
|
||||
"comment": "COMMENT",
|
||||
"approve": "APPROVE",
|
||||
"request_changes": "REQUEST_CHANGES",
|
||||
}[decision],
|
||||
"commit_id": current_sha,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
review = response.json()
|
||||
if not isinstance(review, dict):
|
||||
raise ValueError("Gitea review response was not an object")
|
||||
return {
|
||||
"id": review.get("id"),
|
||||
"state": review.get("state") or "COMMENT",
|
||||
"url": _safe_web_url(review.get("html_url")),
|
||||
}
|
||||
|
||||
|
||||
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 = []
|
||||
|
||||
+638
-98
@@ -1,17 +1,23 @@
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, PositiveInt, field_validator
|
||||
|
||||
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,
|
||||
@@ -20,11 +26,48 @@ 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
|
||||
ISSUE_ACTION_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):
|
||||
@@ -34,13 +77,92 @@ class ContextPayloadError(ValueError):
|
||||
class ReadinessPayloadError(ValueError):
|
||||
"""Raised when Gitea returns a structurally invalid readiness payload."""
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
class NotificationReadBatch(BaseModel):
|
||||
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
class NotificationReply(BaseModel):
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def strip_body(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("reply must not be blank")
|
||||
return value
|
||||
|
||||
|
||||
class IssueComment(BaseModel):
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def strip_body(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("comment must not be blank")
|
||||
return value
|
||||
|
||||
|
||||
class PullReviewSubmission(BaseModel):
|
||||
decision: str
|
||||
body: str = Field(max_length=10_000)
|
||||
expected_head_sha: str
|
||||
|
||||
@field_validator("decision")
|
||||
@classmethod
|
||||
def validate_decision(cls, value: str) -> str:
|
||||
if value not in {"comment", "approve", "request_changes"}:
|
||||
raise ValueError("unsupported review decision")
|
||||
return value
|
||||
|
||||
|
||||
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.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||
app.include_router(frontend_router)
|
||||
@@ -49,9 +171,12 @@ 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"} or (
|
||||
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") or (
|
||||
request.url.path.startswith("/api/v1/repos/")
|
||||
and "/issues/" in request.url.path
|
||||
):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
@@ -144,87 +269,244 @@ async def context() -> JSONResponse:
|
||||
}, headers={
|
||||
"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))
|
||||
} if isinstance(e, TimeoutError) else None)
|
||||
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"))
|
||||
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=[
|
||||
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")
|
||||
)
|
||||
_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["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"],
|
||||
_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
|
||||
)
|
||||
)
|
||||
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")
|
||||
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
|
||||
)
|
||||
)
|
||||
]
|
||||
ctx = compute(user_model, repo_models, issue_models, pr_models)
|
||||
return JSONResponse(ctx.model_dump())
|
||||
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")
|
||||
@@ -260,23 +542,281 @@ async def event_stream():
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review")
|
||||
async def review_detail(owner: str, repo: str, number: int):
|
||||
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.post("/api/v1/notifications/{thread_id}/reply", status_code=201)
|
||||
async def reply_to_notification(
|
||||
reply: NotificationReply, thread_id: int = PathParam(gt=0)
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.reply_to_notification(thread_id, reply.body),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": (
|
||||
"The reply could not be posted. Your draft is safe; please retry."
|
||||
)
|
||||
},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
|
||||
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_assigned_issue():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await gitea_proxy.issue_detail(repository, number)
|
||||
|
||||
try:
|
||||
repository = f"{owner}/{repo}"
|
||||
if not await asyncio.wait_for(
|
||||
is_requested_review(repository, number),
|
||||
timeout=CONTEXT_TIMEOUT_SECONDS,
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Review request not found")
|
||||
return await asyncio.wait_for(
|
||||
pull_review_detail(repository, number),
|
||||
timeout=CONTEXT_TIMEOUT_SECONDS,
|
||||
load_assigned_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Loading the issue timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The issue is temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", status_code=201)
|
||||
async def comment_on_assigned_issue(
|
||||
comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def post_comment():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(post_comment(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The comment could not be posted. Your draft is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/close")
|
||||
async def close_assigned_issue(owner: str, repo: str, number: int = PathParam(gt=0)):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def close_issue():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await gitea_proxy.close_issue(repository, number)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(close_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The issue could not be closed. It remains in My Work; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
|
||||
async def submit_review(
|
||||
submission: PullReviewSubmission, owner: str, repo: str, number: int
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def submit_requested_review():
|
||||
if not await is_requested_review(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Review request not found")
|
||||
return await gitea_proxy.submit_pull_review(
|
||||
repository,
|
||||
number,
|
||||
submission.expected_head_sha,
|
||||
submission.decision,
|
||||
submission.body,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
submit_requested_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except gitea_proxy.StaleReviewError:
|
||||
return JSONResponse(
|
||||
{"error": "New commits were pushed. Refresh the review before submitting."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The review could not be submitted. Your draft is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, status_code=201)
|
||||
@@ -12,7 +12,7 @@ async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkey
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def empty_events():
|
||||
async def empty_events(user_data=None):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
@@ -23,7 +23,7 @@ async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkey
|
||||
|
||||
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"):
|
||||
for path in ("/api/v1/context", "/api/v1/events", "/api/v1/live"):
|
||||
response = await client.get(path)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -15,6 +15,8 @@ def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
urljoin("https://forge.alexanderwhitestone.com/dashboard/", path)
|
||||
for path in api_paths
|
||||
} == {
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/context",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/events",
|
||||
"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",
|
||||
}
|
||||
@@ -17,8 +17,9 @@ def test_dashboard_has_realtime_gitea_event_stream_widget():
|
||||
assert "Gitea event stream" in html
|
||||
assert "id=\"gitea-events\"" in html
|
||||
assert "function paintEventStream" in html
|
||||
assert "setInterval(() => { if (!document.hidden) loadEventStream(); }, 5000)" in html
|
||||
assert "if (!document.hidden) loadEventStream();" 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
|
||||
|
||||
@@ -27,7 +28,6 @@ def test_event_stream_reports_when_activity_was_refreshed():
|
||||
html = DASHBOARD.read_text()
|
||||
|
||||
assert 'id="gitea-events-status"' in html
|
||||
assert "setEventStreamStatus('Updating…')" in html
|
||||
assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html
|
||||
|
||||
|
||||
@@ -121,6 +121,21 @@ async def test_activity_events_fetches_feed_for_authenticated_user(monkeypatch):
|
||||
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():
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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"] == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("subject_kind", ["issues", "pulls"])
|
||||
async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kind):
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append((request.method, str(request.url), request.content))
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"id": 42,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Pull" if subject_kind == "pulls" else "Issue",
|
||||
"url": (
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/"
|
||||
f"{subject_kind}/7"
|
||||
),
|
||||
},
|
||||
})
|
||||
return httpx.Response(201, json={
|
||||
"id": 91,
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.reply_to_notification(42, "Please retry the worker.")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
(
|
||||
"GET",
|
||||
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
|
||||
b"",
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
b'{"body":"Please retry the worker."}',
|
||||
),
|
||||
]
|
||||
assert result == {
|
||||
"id": 91,
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
@@ -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)"]
|
||||
@@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
@@ -54,26 +56,55 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_requested_review_guard_matches_repository_number_and_reason(monkeypatch):
|
||||
async def pulls():
|
||||
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"},
|
||||
"work_reasons": ["review_requested"],
|
||||
},
|
||||
{
|
||||
"number": 8,
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"work_reasons": ["assigned_to_me"],
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "pull_requests", pulls)
|
||||
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", 8) is False
|
||||
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
|
||||
@@ -175,6 +206,7 @@ async def test_pull_review_detail_combines_pr_files_status_and_reviews(monkeypat
|
||||
"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"
|
||||
@@ -182,6 +214,45 @@ async def test_pull_review_detail_combines_pr_files_status_and_reviews(monkeypat
|
||||
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):
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
|
||||
async def assigned(repository, number):
|
||||
return (repository, number) == ("stackchain/api", 7)
|
||||
|
||||
async def detail(repository, number):
|
||||
return {"repository": repository, "number": number, "title": "Fix mobile flow"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_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/issues/7/detail")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"repository": "stackchain/api", "number": 7, "title": "Fix mobile flow"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def assigned(repository, number):
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main, "ISSUE_ACTION_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/issues/7/detail")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.json() == {"error": "Loading the issue timed out. Please retry."}
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "json_body"),
|
||||
[
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/7/detail", None),
|
||||
("POST", "/api/v1/repos/stackchain/api/issues/7/comments", {"body": "Hello"}),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/7/close", None),
|
||||
],
|
||||
)
|
||||
async def test_issue_actions_reject_items_not_assigned_to_service_user(
|
||||
monkeypatch, method, path, json_body
|
||||
):
|
||||
async def assigned(repository, number):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.request(method, path, json=json_body)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("body", [" ", "x" * 10_001])
|
||||
async def test_issue_comment_rejects_blank_or_oversized_body_before_upstream(
|
||||
monkeypatch, body
|
||||
):
|
||||
called = False
|
||||
|
||||
async def assigned(repository, number):
|
||||
nonlocal called
|
||||
called = True
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
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/issues/7/comments", json={"body": body}
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_comment_endpoint_posts_only_to_assigned_issue(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
async def comment(repository, number, body):
|
||||
calls.append((repository, number, body))
|
||||
return {"id": 82, "author": "timmy", "body": body}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
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/issues/7/comments",
|
||||
json={"body": " Ready to ship "},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {"id": 82, "author": "timmy", "body": "Ready to ship"}
|
||||
assert calls == [("stackchain/api", 7, "Ready to ship")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_close_endpoint_mutates_assigned_issue_only_after_confirmation(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
async def close(repository, number):
|
||||
calls.append((repository, number))
|
||||
return {"number": number, "state": "closed", "closed_at": "now"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch("/api/v1/repos/stackchain/api/issues/7/close")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["state"] == "closed"
|
||||
assert calls == [("stackchain/api", 7)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_close_issue_patches_state_and_confirms_closed_response():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"number": 7, "state": "closed", "closed_at": "2026-08-07T10:20:00Z"},
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.close_issue("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0].method == "PATCH"
|
||||
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues/7"
|
||||
assert requests[0].content == b'{"state":"closed"}'
|
||||
assert result == {"number": 7, "state": "closed", "closed_at": "2026-08-07T10:20:00Z"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_issue_comment_posts_body_and_returns_safe_identity():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"id": 82,
|
||||
"body": "Ready to ship",
|
||||
"created_at": "2026-08-07T10:10:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-82",
|
||||
"user": {"login": "timmy"},
|
||||
},
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.comment_on_issue("stackchain/api", 7, "Ready to ship")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 1
|
||||
assert requests[0].method == "POST"
|
||||
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues/7/comments"
|
||||
assert requests[0].content == b'{"body":"Ready to ship"}'
|
||||
assert result == {
|
||||
"id": 82,
|
||||
"author": "timmy",
|
||||
"body": "Ready to ship",
|
||||
"created_at": "2026-08-07T10:10:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-82",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path, request.url.query.decode()))
|
||||
if request.url.path.endswith("/comments"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"id": 81,
|
||||
"body": "Latest update",
|
||||
"created_at": "2026-08-07T10:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
|
||||
"user": {"login": "sam"},
|
||||
}
|
||||
],
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"number": 7,
|
||||
"title": "Fix mobile flow",
|
||||
"state": "open",
|
||||
"body": "Full issue context",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": [{"name": "P1"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
},
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.issue_detail("stackchain/api", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/7", ""),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
|
||||
]
|
||||
assert result == {
|
||||
"repository": "stackchain/api",
|
||||
"number": 7,
|
||||
"title": "Fix mobile flow",
|
||||
"state": "open",
|
||||
"body": "Full issue context",
|
||||
"url": "https://forge.example/stackchain/api/issues/7",
|
||||
"labels": ["P1"],
|
||||
"assignees": ["timmy"],
|
||||
"comments": [
|
||||
{
|
||||
"id": 81,
|
||||
"author": "sam",
|
||||
"body": "Latest update",
|
||||
"created_at": "2026-08-07T10:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
+999
-2
File diff suppressed because it is too large.
Load diff
@@ -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,90 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_reply_api_validates_and_posts_without_marking_read(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def reply(thread_id, body):
|
||||
calls.append((thread_id, body))
|
||||
return {
|
||||
"id": 91,
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
|
||||
async def must_not_mark_read(_thread_id):
|
||||
raise AssertionError("replying must not mark the update read")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
|
||||
monkeypatch.setattr(main, "mark_notification_read", must_not_mark_read)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/notifications/42/reply",
|
||||
json={"body": " Please retry the worker. "},
|
||||
)
|
||||
empty = await client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": " "}
|
||||
)
|
||||
oversized = await client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "x" * 10_001}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {
|
||||
"id": 91,
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert empty.status_code == 422
|
||||
assert oversized.status_code == 422
|
||||
assert calls == [(42, "Please retry the worker.")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("failure", ["timeout", "upstream"])
|
||||
async def test_notification_reply_failure_is_sanitized_retryable_and_no_store(
|
||||
monkeypatch, failure
|
||||
):
|
||||
async def reply(_thread_id, _body):
|
||||
if failure == "timeout":
|
||||
await asyncio.sleep(0.05)
|
||||
raise httpx.HTTPError("token=secret upstream exploded")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
|
||||
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_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.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "Retry it"}
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {
|
||||
"error": "The reply could not be posted. Your draft is safe; please retry."
|
||||
}
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert "secret" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_reply_cannot_be_invoked_by_a_foreign_browser_origin():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.options(
|
||||
"/api/v1/notifications/42/reply",
|
||||
headers={
|
||||
"Origin": "https://evil.example",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code != 200
|
||||
assert "access-control-allow-origin" not in response.headers
|
||||
+220
-4
@@ -1,7 +1,9 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -49,13 +51,227 @@ async def test_review_detail_rejects_pulls_not_requested_from_service_user(monke
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_public_review_endpoint_does_not_expose_service_token_mutations():
|
||||
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_review_submission_posts_validated_decision_for_requested_current_head(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def requested(repository, number):
|
||||
return (repository, number) == ("stackchain/api", 7)
|
||||
|
||||
async def submit(repository, number, expected_head_sha, decision, body):
|
||||
calls.append((repository, number, expected_head_sha, decision, body))
|
||||
return {"id": 91, "state": "APPROVED", "url": "https://forge.example/reviews/91"}
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit, raising=False)
|
||||
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"},
|
||||
json={
|
||||
"decision": "approve",
|
||||
"body": "Looks good on mobile.",
|
||||
"expected_head_sha": "abc123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == 201
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"id": 91,
|
||||
"state": "APPROVED",
|
||||
"url": "https://forge.example/reviews/91",
|
||||
}
|
||||
assert calls == [
|
||||
("stackchain/api", 7, "abc123", "approve", "Looks good on mobile.")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_review_submission_checks_head_then_maps_decision_upstream():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={"head": {"sha": "abc123"}})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 91,
|
||||
"state": "APPROVED",
|
||||
"html_url": "https://forge.example/reviews/91",
|
||||
},
|
||||
)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.submit_pull_review(
|
||||
"stackchain/api", 7, "abc123", "approve", "Looks good."
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||
("POST", "/api/v1/repos/stackchain/api/pulls/7/reviews"),
|
||||
]
|
||||
assert requests[1].content == b'{"body":"Looks good.","event":"APPROVE","commit_id":"abc123"}'
|
||||
assert result == {
|
||||
"id": 91,
|
||||
"state": "APPROVED",
|
||||
"url": "https://forge.example/reviews/91",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_review_submission_rejects_changed_head_without_posting():
|
||||
methods = []
|
||||
|
||||
async def handler(request):
|
||||
methods.append(request.method)
|
||||
return httpx.Response(200, json={"head": {"sha": "new456"}})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.StaleReviewError):
|
||||
await gitea_proxy.submit_pull_review(
|
||||
"stackchain/api", 7, "abc123", "request_changes", "Please revise."
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert methods == ["GET"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_submission_returns_conflict_when_pull_head_changed(monkeypatch):
|
||||
async def requested(repository, number):
|
||||
return True
|
||||
|
||||
async def submit(*args):
|
||||
raise gitea_proxy.StaleReviewError("changed")
|
||||
|
||||
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||
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={
|
||||
"decision": "request_changes",
|
||||
"body": "Please revise.",
|
||||
"expected_head_sha": "abc123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": "New commits were pushed. Refresh the review before submitting."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_submission_rejects_unsupported_decision_before_upstream(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def requested(repository, number):
|
||||
nonlocal called
|
||||
called = True
|
||||
return True
|
||||
|
||||
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.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={
|
||||
"decision": "merge",
|
||||
"body": "Ship it.",
|
||||
"expected_head_sha": "abc123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_submission_rejects_oversized_feedback_before_upstream(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def requested(repository, number):
|
||||
nonlocal called
|
||||
called = True
|
||||
return True
|
||||
|
||||
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.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={
|
||||
"decision": "comment",
|
||||
"body": "x" * 10_001,
|
||||
"expected_head_sha": "abc123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_submission_deadline_cancels_request_check(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 asyncio.wait_for(
|
||||
client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||
json={
|
||||
"decision": "comment",
|
||||
"body": "Review note.",
|
||||
"expected_head_sha": "abc123",
|
||||
},
|
||||
),
|
||||
timeout=0.2,
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert cancelled.is_set()
|
||||
|
||||
@@ -14,4 +14,33 @@ async def test_one_context_snapshot_updates_every_context_backed_panel():
|
||||
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
|
||||
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
|
||||
Reference in new issue
Block a user