Compare commits

...
Author SHA1 Message Date
timmy 87058d72e6 feat: release mobile issue assignments (#183)
CI / lint (pull_request) Successful in 14s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 09:24:21 +00:00
rockachopa e96ff42dff Merge pull request 'Make Find Work production-schema-safe and truthfully paginated' (#182) from timmy/181-find-work-schema-pagination into main
CI / lint (push) Successful in 12s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 08:59:02 +00:00
timmy b6c7ec83e4 feat: make Find Work production-schema-safe (#181)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 08:58:07 +00:00
rockachopa b4f984e331 Preview issue details before claiming mobile work (#180)
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #179
2026-08-07 08:23:29 +00:00
timmy b4b13a7db1 feat: preview issue details before claiming (#179)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 08:22:43 +00:00
rockachopa 1947f878e9 Make mobile issue capture idempotent across retries (#178)
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #177
2026-08-07 07:55:30 +00:00
timmy 6f54d5764b feat: make mobile issue capture idempotent (#177)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 07:54:42 +00:00
rockachopa 9293dde1af Merge pull request 'Discover and claim unassigned issues from mobile My Work' (#176) from timmy/175-mobile-find-work into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 07:31:45 +00:00
timmy 1f997cc504 feat: discover and claim mobile work (#175)
CI / lint (pull_request) Successful in 14s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 07:30:43 +00:00
rockachopa f9e11fca6a Merge pull request 'Reprioritize assigned issues from the mobile issue sheet' (#174) from timmy/173-mobile-issue-labels into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 06:53:49 +00:00
timmy 390ad0d4d2 feat: reprioritize assigned issues from mobile (#173)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 06:52:53 +00:00
timmy b673911e36 Merge pull request 'Make My Work authorization constant-request and deadline-safe' (#172) from timmy/171-constant-request-work-auth into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 05:53:22 +00:00
timmy 484c0d4aeb perf: bound My Work authorization requests (#171)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 05:52:28 +00:00
rockachopa 5ce29fbe7d Merge pull request 'Paginate mobile My Work beyond the first 50 items' (#170) from timmy/169-paginate-mobile-my-work into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
2026-08-07 05:36:55 +00:00
timmy 625e65e7e4 feat: paginate mobile My Work queues (#169)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 05:35:45 +00:00
rockachopa 29a786cea3 Merge pull request 'Keep healthy live sections refreshing during partial outages' (#168) from timmy/167-section-isolated-live-refresh into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 04:54:28 +00:00
timmy 4870b58c5b feat: isolate live section refresh backoff (#167)
CI / lint (pull_request) Successful in 15s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 04:53:42 +00:00
timmy 5fe0b28747 Merge pull request 'Add draft-safe inline comments to mobile pull request reviews' (#166) from timmy/165-mobile-inline-review-comments into main
CI / lint (push) Successful in 12s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 6s
2026-08-07 04:29:40 +00:00
timmy 58376f6d45 feat: add inline mobile review comments (#165)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 04:28:44 +00:00
timmy 4b7405aa65 Merge pull request 'Select priority labels while creating mobile issues' (#164) from timmy/163-mobile-issue-labels into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 03:56:56 +00:00
timmy b6390d3298 feat: triage mobile issue capture with labels (#163)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 03:55:51 +00:00
timmy e5f001f2b0 Merge pull request 'Complete assigned pull requests from mobile My Work' (#162) from timmy/161-mobile-assigned-pr-completion into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 03:31:10 +00:00
timmy 745c3779c2 feat: complete assigned pull requests from mobile (#161)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 03:30:21 +00:00
timmy 35e46f81f1 Merge pull request 'Create and self-assign issues from mobile My Work' (#160) from timmy/159-mobile-issue-capture into main
CI / lint (push) Successful in 15s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 02:59:05 +00:00
21 changed files with 4197 additions and 238 deletions

No files matched your search

+15 -5
View File
@@ -15,11 +15,21 @@ python3 -m pip install -r requirements.txt
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, 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; mobile issue capture requires issue
creation and assignment permission, while closing an assigned issue and native
Comment, Approve, and Request changes reviews require repository write permission.
threads, create and self-assign issues, discover, claim, and release issue assignments,
create issue comments, close assigned issues, inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue
creation and assignment permission. Issue capture persists a per-draft idempotency key,
so retrying after a timeout or reload replays a confirmed creation instead of posting a
duplicate; callers integrating directly should preserve the `Idempotency-Key` header
with the unchanged payload until a `201` response is confirmed. Closing an assigned issue, native Comment,
Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. Native Comment, Approve, and Request changes reviews support
head-scoped draft comments anchored to changed lines; the dashboard validates each
comment path and submits the summary, decision, and inline comments in one review
request. The dashboard rechecks the current pull-request head, CI success, draft
state, and mergeability immediately before every merge.
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:
+66 -17
View File
@@ -1,30 +1,59 @@
function createIssueCapture({ fetchJson, storage }) {
function newIssueOperationId() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
if (globalThis.crypto?.getRandomValues) {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
}
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
}
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
const storageKey = 'stackchain.issue-capture.v1';
let pending = null;
const emptyDraft = () => ({ repository: '', title: '', body: '' });
const safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
function loadStored() {
try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''};
return {
repository: String(parsed.repository || ''),
title: String(parsed.title || ''),
body: String(parsed.body || ''),
labelIds: safeLabelIds(parsed.labelIds),
operationId: String(parsed.operationId || '').slice(0, 128),
};
} catch (_error) {
return {...emptyDraft(), operationId: ''};
}
}
function writeStored(record) {
try { storage.setItem(storageKey, JSON.stringify(record)); }
catch (_error) { /* Keep the form as the in-memory fallback. */ }
}
function saveDraft(draft) {
const previous = loadStored();
const safe = {
repository: String(draft?.repository || ''),
title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: safeLabelIds(draft?.labelIds),
};
try { storage.setItem(storageKey, JSON.stringify(safe)); }
catch (_error) { /* Keep the form as the in-memory fallback. */ }
const unchanged = ['repository', 'title', 'body'].every(key => previous[key] === safe[key]) &&
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
return safe;
}
function loadDraft() {
try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
return parsed && typeof parsed === 'object' ? {
repository: String(parsed.repository || ''),
title: String(parsed.title || ''),
body: String(parsed.body || ''),
} : emptyDraft();
} catch (_error) {
return emptyDraft();
}
const {operationId: _operationId, ...draft} = loadStored();
return draft;
}
function clearDraft() {
@@ -32,14 +61,34 @@ function createIssueCapture({ fetchJson, storage }) {
catch (_error) { /* Confirmed creation remains authoritative. */ }
}
function loadLabels(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
const priorities = new Set(['p0', 'priority-high', 'critical']);
return fetchJson('api/v1/repos/' + encoded + '/labels').then(labels =>
(Array.isArray(labels) ? labels : []).slice().sort((left, right) => {
const leftPriority = priorities.has(String(left?.name || '').toLowerCase());
const rightPriority = priorities.has(String(right?.name || '').toLowerCase());
return Number(rightPriority) - Number(leftPriority);
})
);
}
function submit(draft) {
if (pending) return pending;
const saved = saveDraft(draft);
const stored = loadStored();
const operationId = stored.operationId || String(createOperationId()).slice(0, 128);
writeStored({...saved, operationId});
const repository = saved.repository.split('/').map(encodeURIComponent).join('/');
pending = fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ title: saved.title, body: saved.body }),
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': operationId,
},
body: JSON.stringify({
title: saved.title, body: saved.body, label_ids: saved.labelIds,
}),
}).then(issue => {
clearDraft();
return issue;
@@ -47,7 +96,7 @@ function createIssueCapture({ fetchJson, storage }) {
return pending;
}
return { saveDraft, loadDraft, submit };
return { saveDraft, loadDraft, loadLabels, submit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
+628 -10
View File
@@ -69,6 +69,8 @@ textarea { resize: vertical; min-height: 120px; }
.read-update { min-height:44px; width:100%; }
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
.load-more-notifications[hidden] { display:none; }
.load-more-work { min-height:44px; width:100%; margin-top:10px; }
.load-more-work[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; }
@@ -97,6 +99,13 @@ textarea { resize: vertical; min-height: 120px; }
.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-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; }
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
.review-inline-composer[hidden] { display:none; }
.review-inline-composer textarea { min-height:96px; width:100%; }
.review-inline-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.review-inline-actions button { min-height:44px; }
.review-diff-line.hunk { color:#93c5fd; }
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
@@ -125,11 +134,30 @@ textarea { resize: vertical; min-height: 120px; }
.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-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
.issue-label-option input { width:20px; height:20px; flex:0 0 auto; }
.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; }
.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; }
.new-issue { min-height:44px; }
.find-work-action { min-height:44px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.find-work-sheet.open { display:flex; }
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
.find-work-list { display:grid; gap:10px; }
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.find-work-card button { width:100%; font-weight:700; }
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.create-issue-sheet.open { display:flex; }
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
@@ -138,7 +166,22 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-form { display:grid; gap:12px; }
.create-issue-form label { display:grid; gap:6px; }
.create-issue-form select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.create-issue-label-option input { width:20px; height:20px; margin:0; }
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.pull-sheet.open { display:flex; }
.pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.pull-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.pull-sheet-header button, .pull-sheet-actions button, .pull-sheet-actions a, .pull-comment-composer button { min-height:44px; }
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
.pull-comment-composer textarea { width:100%; min-height:110px; resize:vertical; }
.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
@media (max-width: 600px) {
header { align-items:flex-start; }
.my-work { margin:0; }
@@ -149,6 +192,7 @@ textarea { resize: vertical; min-height: 120px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
.create-issue-panel { width:100%; border-left:0; padding:14px; }
.pull-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; }
@@ -174,7 +218,10 @@ textarea { resize: vertical; min-height: 120px; }
<h2>My Work</h2>
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
</div>
<button class="new-issue" id="new-issue" type="button">New issue</button>
<div class="my-work-actions">
<button class="find-work-action" id="find-work" type="button">Find work</button>
<button class="new-issue" id="new-issue" type="button">New issue</button>
</div>
<div class="work-filters" aria-label="Filter My Work">
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
@@ -184,6 +231,8 @@ textarea { resize: vertical; min-height: 120px; }
</div>
</div>
<div class="my-work-list" id="my-work-list"></div>
<div class="small" id="work-page-status" aria-live="polite"></div>
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
<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>
@@ -288,6 +337,12 @@ textarea { resize: vertical; min-height: 120px; }
<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>
<fieldset class="issue-label-editor" id="issue-label-editor" aria-describedby="issue-label-status">
<legend>Labels</legend>
<div class="issue-label-list" id="issue-label-list"></div>
<div class="small" id="issue-label-status" aria-live="polite">Load an issue to edit labels.</div>
<button id="save-issue-labels" type="button" disabled>Save labels</button>
</fieldset>
<p class="issue-sheet-content" id="issue-sheet-body"></p>
<h2>Recent discussion</h2>
<div id="issue-comments"></div>
@@ -298,12 +353,26 @@ textarea { resize: vertical; min-height: 120px; }
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
</section>
<div class="issue-sheet-actions">
<button id="release-issue" type="button">Release assignment</button>
<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="find-work-sheet" id="find-work-sheet" role="dialog" aria-modal="true" aria-labelledby="find-work-heading">
<section class="find-work-panel">
<div class="find-work-header">
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
<button id="close-find-work" type="button">Close</button>
</div>
<p class="small">Claim an available issue and continue it in My Work.</p>
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
<div class="find-work-list" id="find-work-list"></div>
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
</section>
</div>
<div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
<section class="create-issue-panel">
<div class="create-issue-header">
@@ -320,6 +389,11 @@ textarea { resize: vertical; min-height: 120px; }
<label for="create-issue-body">Description <span class="small">Optional</span>
<textarea id="create-issue-body" maxlength="10000"></textarea>
</label>
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
<legend>Labels <span class="small">Optional</span></legend>
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
<div class="create-issue-label-list" id="create-issue-label-list"></div>
</fieldset>
<div class="small">The issue will be assigned to you.</div>
<div class="create-issue-actions">
<button id="submit-new-issue" type="submit">Create &amp; assign to me</button>
@@ -365,6 +439,31 @@ textarea { resize: vertical; min-height: 120px; }
</section>
</div>
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
<section class="pull-sheet-panel">
<div class="pull-sheet-header">
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div>
<button id="close-pull-sheet" type="button">Close</button>
</div>
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Checking merge status</span></div>
<p class="pull-sheet-content" id="pull-sheet-body"></p>
<h2>Changed files</h2><div id="pull-files"></div>
<h2>Recent discussion</h2><div id="pull-comments"></div>
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2>
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-pull-comment" type="button">Post comment</button>
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
</section>
<div class="pull-sheet-actions">
<button id="merge-pull" type="button" disabled>Merge</button>
<a id="open-pull-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">
@@ -380,6 +479,16 @@ textarea { resize: vertical; min-height: 120px; }
<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>
<section class="review-inline-composer" id="review-inline-composer" aria-labelledby="review-inline-title" hidden>
<strong id="review-inline-title">Comment on changed line</strong>
<label class="small" id="review-inline-location" for="review-inline-body"></label>
<textarea id="review-inline-body" maxlength="10000" placeholder="Write a line-specific comment"></textarea>
<div class="review-inline-actions">
<button id="save-inline-comment" type="button">Save draft comment</button>
<button id="delete-inline-comment" type="button">Delete draft</button>
<button id="cancel-inline-comment" type="button">Cancel</button>
</div>
</section>
<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>
@@ -416,8 +525,10 @@ 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/pick-work.js"></script>
<script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script>
<script src="static/pull-sheet.js"></script>
<script src="static/review-sheet.js"></script>
<script src="static/context-poller.js"></script>
<script>
@@ -458,6 +569,7 @@ textarea { resize: vertical; min-height: 120px; }
let lastNotifications = [];
let lastContextSnapshot = null;
let notificationPagination = { page: 1, total: 0, has_more: false };
let workPagination = {};
let hasContextSnapshot = false;
let selectedReview = null;
let reviewTrigger = null;
@@ -465,11 +577,17 @@ textarea { resize: vertical; min-height: 120px; }
let updateTrigger = null;
let selectedIssue = null;
let issueTrigger = null;
let selectedPull = null;
let pullTrigger = null;
let selectedPullDetail = null;
let creatingIssue = false;
let findingWork = false;
let availablePagination = { page: 1, total: 0, has_more: false };
let progress = null;
let draft = null;
let reviewFiles = [];
let selectedReviewHead = '';
let activeInlineTarget = null;
let bulkConfirmationPending = false;
let bulkMarkPending = false;
let reviewHandoffPending = false;
@@ -483,6 +601,16 @@ textarea { resize: vertical; min-height: 120px; }
const reviewController = createReviewController({ fetchJson: fetchReviewJson });
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const findWorkController = createFindWork({
fetchJson: fetchReviewJson,
onItems: renderAvailableIssues,
onPagination: pagination => {
availablePagination = pagination;
qs('#load-more-available').hidden = !pagination.has_more;
},
onStatus: message => { qs('#find-work-status').textContent = message; },
});
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
function setClock() { qs('#clock').textContent = fmt(new Date()); }
@@ -533,6 +661,15 @@ textarea { resize: vertical; min-height: 120px; }
return payload;
}
async function fetchWorkPage(stream, page) {
const response = await fetch('api/v1/work/' + encodeURIComponent(stream) + '?page=' + encodeURIComponent(page), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading older work failed.');
return payload;
}
async function postNotificationReply(notificationId, body) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
method: 'POST',
@@ -579,6 +716,20 @@ textarea { resize: vertical; min-height: 120px; }
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const workPager = createWorkPager({
load: fetchWorkPage,
onItems: (stream, items) => {
if (!lastContextSnapshot) return;
if (stream === 'issue') lastContextSnapshot.issues = items;
else lastContextSnapshot.pull_requests = items;
paintMyWork(lastContextSnapshot);
},
onPagination: pagination => {
workPagination = pagination;
updateWorkPaginationControls();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const notificationReplier = createNotificationReplier({
post: postNotificationReply,
storage: localStorage,
@@ -636,7 +787,25 @@ textarea { resize: vertical; min-height: 120px; }
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
if (lastContextSnapshot && Object.values(workPagination).some(page => page.page > 1)) {
const merge = (older, latest) => {
const byId = new Map((older || []).map(item => [item.id, item]));
(latest || []).forEach(item => {
const current = byId.get(item.id) || {};
const reasons = Array.from(new Set(
(current.work_reasons || []).concat(item.work_reasons || [])
));
byId.set(item.id, {
...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}),
});
});
return Array.from(byId.values());
};
data.issues = merge(lastContextSnapshot.issues, data.issues);
data.pull_requests = merge(lastContextSnapshot.pull_requests, data.pull_requests);
}
lastContextSnapshot = data;
if (data.work_pagination) workPager.reset(data.work_pagination);
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>' +
@@ -690,11 +859,36 @@ textarea { resize: vertical; min-height: 120px; }
qs('#my-work').removeAttribute('data-stale');
qs('#my-work-status').textContent = lastMyWork.length ?
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
updateWorkPaginationControls();
renderMyWork();
}
function activeWorkStreams() {
if (selectedWorkFilter === 'issue') return ['issue'];
if (selectedWorkFilter === 'pull') return ['pull'];
if (selectedWorkFilter === 'review') return ['review'];
if (selectedWorkFilter === 'all') return ['issue', 'pull', 'review'];
return [];
}
function updateWorkPaginationControls() {
const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests' };
const streams = activeWorkStreams();
const incomplete = streams.filter(stream => workPagination[stream]?.has_more);
const summaries = streams.flatMap(stream => {
const page = workPagination[stream];
return page && page.total ?
[Math.min(page.total, page.page * 50) + ' of ' + page.total + ' ' + labels[stream]] : [];
});
qs('#work-page-status').textContent = summaries.join(' · ');
qs('#load-more-work').hidden = incomplete.length === 0;
qs('#load-more-work').textContent = incomplete.length ?
'Load older ' + labels[incomplete[0]] : 'Load older work';
}
function renderMyWork() {
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
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 =
@@ -713,14 +907,22 @@ textarea { resize: vertical; min-height: 120px; }
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>';
}
if (item.kind === 'pull') {
return '<article class="my-work-card"><button class="my-work-card-main pull-trigger" data-pull-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>';
}).join('') : '<div class="muted">' + (incomplete ?
'More work is available. Load the next page.' :
'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-pull-index]').forEach(button => {
button.addEventListener('click', () => openPullSheet(lastMyWork[Number(button.dataset.pullIndex)], button));
});
document.querySelectorAll('[data-update-index]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.updateIndex)];
@@ -799,6 +1001,36 @@ textarea { resize: vertical; min-height: 120px; }
escapeHtml(comment.body || 'No comment body provided.') + '</div></div>';
}
async function loadIssueLabelEditor(item, confirmedNames) {
const list = qs('#issue-label-list');
const status = qs('#issue-label-status');
list.textContent = '';
qs('#save-issue-labels').disabled = true;
status.textContent = 'Loading labels…';
try {
const labels = await issueController.loadLabels(item);
if (selectedIssue !== item) return;
const draftIds = issueController.loadLabelDraft(item);
const selectedIds = draftIds.length ? new Set(draftIds) : new Set(
labels.filter(label => confirmedNames.includes(label.name)).map(label => Number(label.id))
);
list.innerHTML = labels.map(label =>
'<label class="issue-label-option"><input type="checkbox" name="issue-label" value="' +
Number(label.id) + '"' + (selectedIds.has(Number(label.id)) ? ' checked' : '') + '><span>' +
escapeHtml(label.name) + '</span></label>'
).join('');
status.textContent = labels.length ? 'Choose labels, then save.' : 'This repository has no labels.';
qs('#save-issue-labels').disabled = false;
} catch (_error) {
status.textContent = 'Labels could not be loaded. Retry by reopening this issue.';
}
}
function selectedEditIssueLabelIds() {
return Array.from(document.querySelectorAll('input[name="issue-label"]:checked'))
.map(input => Number(input.value)).filter(Number.isInteger);
}
async function openIssueSheet(item, trigger) {
if (!item) return;
selectedIssue = item;
@@ -813,6 +1045,9 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-comments').textContent = '';
qs('#issue-comment').value = issueController.loadDraft(item);
qs('#issue-comment-status').textContent = '';
qs('#issue-label-list').textContent = '';
qs('#issue-label-status').textContent = 'Loading labels…';
qs('#save-issue-labels').disabled = true;
qs('#retry-issue-load').hidden = true;
qs('#open-issue-gitea').href = item.url || '#';
qs('#send-issue-comment').disabled = false;
@@ -826,6 +1061,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
'<span class="pill">' + escapeHtml(label) + '</span>'
).join(' ');
loadIssueLabelEditor(item, detail.labels || []);
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
qs('#issue-comments').innerHTML = (detail.comments || []).length ?
@@ -846,14 +1082,173 @@ textarea { resize: vertical; min-height: 120px; }
if (issueTrigger?.isConnected) issueTrigger.focus();
}
async function openPullSheet(item, trigger) {
if (!item) return;
selectedPull = item;
pullTrigger = trigger;
selectedPullDetail = null;
qs('#pull-sheet').classList.add('open');
qs('#pull-sheet-key').textContent = item.key || '';
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
qs('#pull-sheet-status').textContent = 'Loading pull request…';
qs('#pull-sheet-body').textContent = '';
qs('#pull-files').textContent = '';
qs('#pull-comments').textContent = '';
qs('#pull-comment').value = pullController.loadDraft(item);
qs('#pull-comment-status').textContent = '';
qs('#pull-ci-state').textContent = 'CI unknown';
qs('#pull-merge-state').textContent = 'Checking merge status';
qs('#merge-pull').disabled = true;
qs('#retry-pull-load').hidden = true;
qs('#open-pull-gitea').href = item.url || '#';
qs('#close-pull-sheet').focus();
try {
const detail = await pullController.load(item);
if (selectedPull !== item) return;
selectedPullDetail = detail;
const eligibility = createPullSheet.mergeEligibility(detail);
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
qs('#pull-sheet-body').textContent = detail.body || 'No description provided.';
qs('#pull-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map(file =>
'<div class="pull-file"><strong>' + escapeHtml(file.filename) + '</strong><div class="small">' +
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</div></div>'
).join('') : '<div class="muted">No changed files reported.</div>';
qs('#pull-comments').innerHTML = (detail.comments || []).length ? detail.comments.map(comment =>
'<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>'
).join('') : '<div class="muted">No comments yet.</div>';
qs('#open-pull-gitea').href = detail.url || item.url || '#';
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
} catch (error) {
if (selectedPull !== item) return;
qs('#pull-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
qs('#retry-pull-load').hidden = false;
qs('#retry-pull-load').focus();
}
}
function closePullSheet() {
qs('#pull-sheet').classList.remove('open');
selectedPull = null;
selectedPullDetail = null;
if (pullTrigger?.isConnected) pullTrigger.focus();
}
function selectedIssueLabelIds() {
return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked'))
.map(input => Number(input.value)).filter(Number.isInteger);
}
function renderAvailableIssues(items) {
const list = qs('#find-work-list');
list.innerHTML = items.length ? items.map((item, index) => {
const expanded = findWorkController.isPreviewed(item);
const detailId = 'find-work-detail-' + index;
const detail = '<div id="' + detailId + '" class="find-work-detail"' + (expanded ? '' : ' hidden') +
'><p class="find-work-description">' + escapeHtml(item.body || 'No description provided.') + '</p>' +
(item.url ? '<a href="' + escapeHtml(item.url) + '" target="_blank" rel="noopener noreferrer">Open in Gitea</a>' : '') +
'</div>';
return '<article class="find-work-card"><div class="small">' + escapeHtml(item.repository) + '#' +
Number(item.number) + '</div><strong>' + escapeHtml(item.title || 'Untitled issue') + '</strong>' +
'<div>' + (item.labels || []).map(label => '<span class="pill">' + escapeHtml(label) + '</span>').join(' ') +
'</div><button type="button" data-preview-index="' + index + '" aria-expanded="' + expanded +
'" aria-controls="' + detailId + '">' + (expanded ? 'Hide details' : 'View details') + '</button>' +
detail + '<button type="button" data-claim-index="' + index + '">Assign to me</button></article>';
}).join('') : '<div class="muted">No unassigned issues are available on this page.</div>';
list.querySelectorAll('[data-preview-index]').forEach(button => {
button.addEventListener('click', () => {
const item = findWorkController.items()[Number(button.dataset.previewIndex)];
if (!item) return;
const expanded = findWorkController.togglePreview(item);
const detail = document.getElementById(button.getAttribute('aria-controls'));
button.setAttribute('aria-expanded', String(expanded));
button.textContent = expanded ? 'Hide details' : 'View details';
if (detail) detail.hidden = !expanded;
});
});
list.querySelectorAll('[data-claim-index]').forEach(button => {
button.addEventListener('click', async () => {
const item = findWorkController.items()[Number(button.dataset.claimIndex)];
if (!item) return;
button.disabled = true;
try {
const confirmed = await findWorkController.claim(item);
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
lastMyWork = buildMyWork(lastContextSnapshot);
const claimed = lastMyWork.find(work =>
work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number
);
closeFindWorkSheet();
refreshMyWorkView();
qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.';
openIssueSheet(claimed, qs('#find-work'));
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Refresh and retry.';
button.disabled = false;
button.focus();
}
});
});
}
async function openFindWorkSheet() {
findingWork = true;
qs('#find-work-sheet').classList.add('open');
qs('#find-work-list').textContent = '';
qs('#find-work-status').textContent = 'Loading available issues…';
qs('#load-more-available').hidden = true;
qs('#close-find-work').focus();
try {
await findWorkController.load();
qs('#find-work-status').textContent = findWorkController.items().length ?
findWorkController.items().length + ' of ' + availablePagination.total + ' available issues loaded.' :
'No unassigned issues are available.';
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Close and retry.';
}
}
function closeFindWorkSheet() {
findingWork = false;
qs('#find-work-sheet').classList.remove('open');
qs('#find-work').focus();
}
function saveIssueCaptureDraft() {
issueCapture.saveDraft({
repository: qs('#create-issue-repository').value,
title: qs('#create-issue-title').value,
body: qs('#create-issue-body').value,
labelIds: selectedIssueLabelIds(),
});
}
async function loadIssueLabels(repository, selectedIds = []) {
const list = qs('#create-issue-label-list');
const status = qs('#create-issue-label-status');
list.innerHTML = '';
if (!repository) {
status.textContent = 'Choose a repository to load labels.';
return;
}
status.textContent = 'Loading labels…';
try {
const labels = await issueCapture.loadLabels(repository);
const selected = new Set(selectedIds.map(Number));
list.innerHTML = labels.map(label =>
'<label class="create-issue-label-option"><input type="checkbox" name="create-issue-label" value="' +
Number(label.id) + '"' + (selected.has(Number(label.id)) ? ' checked' : '') + '><span>' +
escapeHtml(label.name) + '</span></label>'
).join('');
status.textContent = labels.length ? 'Select labels to triage this issue.' : 'This repository has no labels.';
} catch (error) {
status.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
}
}
function openCreateIssueSheet() {
const captureDraft = issueCapture.loadDraft();
const repositories = lastContextSnapshot?.repos || [];
@@ -865,6 +1260,7 @@ textarea { resize: vertical; min-height: 120px; }
}
qs('#create-issue-title').value = captureDraft.title;
qs('#create-issue-body').value = captureDraft.body;
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
qs('#submit-new-issue').disabled = !repositories.length;
qs('#create-issue-sheet').classList.add('open');
@@ -878,6 +1274,38 @@ textarea { resize: vertical; min-height: 120px; }
qs('#new-issue').focus();
}
function inlineAnchor(target) {
return {
path: target.dataset.reviewFilename,
...(target.dataset.newPosition ? { new_position: Number(target.dataset.newPosition) } : {}),
...(target.dataset.oldPosition ? { old_position: Number(target.dataset.oldPosition) } : {}),
};
}
function sameInlineAnchor(comment, anchor) {
return comment.path === anchor.path && comment.new_position === anchor.new_position &&
comment.old_position === anchor.old_position;
}
function closeInlineComposer() {
qs('#review-inline-composer').hidden = true;
qs('#review-inline-body').value = '';
activeInlineTarget = null;
}
function openInlineComposer(target) {
if (!draft) return;
activeInlineTarget = target;
const anchor = inlineAnchor(target);
const existing = draft.snapshot().comments.find(comment => sameInlineAnchor(comment, anchor));
qs('#review-inline-location').textContent = anchor.path + ' · line ' +
(anchor.new_position || anchor.old_position) + (anchor.new_position ? ' (new)' : ' (old)');
qs('#review-inline-body').value = existing?.body || '';
qs('#delete-inline-comment').disabled = !existing;
qs('#review-inline-composer').hidden = false;
qs('#review-inline-body').focus();
}
async function openReviewSheet(item, trigger) {
selectedReview = item;
reviewTrigger = trigger;
@@ -901,6 +1329,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#review-handoff-status').textContent = '';
qs('#review-submit-status').textContent = '';
qs('#submit-review').disabled = true;
closeInlineComposer();
draft = null;
reviewFiles = [];
selectedReviewHead = '';
@@ -942,6 +1371,13 @@ textarea { resize: vertical; min-height: 120px; }
note.value = draftSnapshot.notes[note.dataset.reviewFilename] || '';
note.addEventListener('input', () => draft.setNote(note.dataset.reviewFilename, note.value));
});
document.querySelectorAll('.review-inline-target').forEach(target => {
const anchor = inlineAnchor(target);
target.classList.toggle('has-draft', draftSnapshot.comments.some(comment =>
sameInlineAnchor(comment, anchor)
));
target.addEventListener('click', () => openInlineComposer(target));
});
document.querySelectorAll('.review-mark').forEach(button => {
button.addEventListener('click', () => {
const snapshot = progress.markReviewed(button.dataset.reviewFilename);
@@ -973,6 +1409,7 @@ textarea { resize: vertical; min-height: 120px; }
draft = null;
reviewFiles = [];
selectedReviewHead = '';
closeInlineComposer();
if (reviewTrigger?.isConnected) reviewTrigger.focus();
}
@@ -1016,8 +1453,12 @@ textarea { resize: vertical; min-height: 120px; }
}
function renderLiveSnapshot(snapshot) {
const notificationsFresh = Array.isArray(snapshot.notifications);
if (notificationsFresh) {
const contextFreshness = snapshot.freshness?.sections?.context;
const eventsFreshness = snapshot.freshness?.sections?.events;
const notificationFreshness = snapshot.freshness?.sections?.notifications;
const hasNotifications = Array.isArray(snapshot.notifications);
const notificationsFresh = hasNotifications && !notificationFreshness?.stale;
if (hasNotifications) {
if (notificationPagination.page > 1) {
const byId = new Map(lastNotifications.map(item => [item.id, item]));
snapshot.notifications.forEach(item => byId.set(item.id, item));
@@ -1038,19 +1479,27 @@ textarea { resize: vertical; min-height: 120px; }
if (snapshot.context) {
snapshot.context.notifications = lastNotifications;
renderContextSnapshot(snapshot.context);
if (!notificationsFresh) markNotificationsStale();
if (contextFreshness?.stale) markMyWorkStale();
else if (!notificationsFresh) markNotificationsStale();
} else handleContextError(new Error('Context section unavailable'));
if (snapshot.events) {
paintEventStream(snapshot.events);
if (Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
if (eventsFreshness?.revalidating) {
setEventStreamStatus('Refreshing activity · showing last activity');
} else if (eventsFreshness?.stale || eventsFreshness?.degraded) {
const retrySeconds = Number(eventsFreshness.retry_in_seconds) || 0;
setEventStreamStatus('Activity refresh failed · showing last activity' +
(retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : ''));
} else if (Array.isArray(snapshot.events)) {
setEventStreamStatus('Updated ' + fmt(new Date()));
} else {
setEventStreamStatus('Update failed · showing last activity');
}
if (snapshot.freshness?.degraded && !snapshot.freshness.revalidating) {
// Compatibility fallback for snapshots produced before section metadata.
if (!eventsFreshness && 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) {
} else if (!eventsFreshness && snapshot.freshness?.revalidating) {
setEventStreamStatus('Refreshing · showing recent snapshot');
}
}
@@ -1123,6 +1572,11 @@ 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.key === 'Escape' && findingWork) {
e.preventDefault();
closeFindWorkSheet();
return;
}
if (e.key === 'Escape' && creatingIssue) {
e.preventDefault();
saveIssueCaptureDraft();
@@ -1134,6 +1588,11 @@ textarea { resize: vertical; min-height: 120px; }
closeIssueSheet();
return;
}
if (e.key === 'Escape' && selectedPull) {
e.preventDefault();
closePullSheet();
return;
}
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
e.preventDefault();
qs('#cmd-palette').classList.toggle('open');
@@ -1144,20 +1603,41 @@ textarea { resize: vertical; min-height: 120px; }
}
});
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#find-work').addEventListener('click', openFindWorkSheet);
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
qs('#load-more-available').addEventListener('click', async event => {
event.currentTarget.disabled = true;
qs('#find-work-status').textContent = 'Loading more available issues…';
try {
await findWorkController.loadMore();
qs('#find-work-status').textContent = findWorkController.items().length + ' of ' +
availablePagination.total + ' available issues loaded.';
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Retry loading more.';
} finally {
event.currentTarget.disabled = false;
}
});
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
qs('#cancel-new-issue').addEventListener('click', () => {
saveIssueCaptureDraft();
closeCreateIssueSheet();
});
['#create-issue-repository', '#create-issue-title', '#create-issue-body'].forEach(selector =>
['#create-issue-title', '#create-issue-body'].forEach(selector =>
qs(selector).addEventListener('input', saveIssueCaptureDraft)
);
qs('#create-issue-repository').addEventListener('change', event => {
loadIssueLabels(event.target.value);
saveIssueCaptureDraft();
});
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-form').addEventListener('submit', async event => {
event.preventDefault();
const captureDraft = {
repository: qs('#create-issue-repository').value,
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
labelIds: selectedIssueLabelIds(),
};
if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
@@ -1191,6 +1671,29 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-comment').addEventListener('input', event => {
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
});
qs('#save-issue-labels').addEventListener('click', async () => {
if (!selectedIssue || !lastContextSnapshot) return;
const editing = selectedIssue;
const button = qs('#save-issue-labels');
button.disabled = true;
qs('#issue-label-status').textContent = 'Saving labels…';
try {
const confirmed = await issueController.updateLabels(selectedIssue, selectedEditIssueLabelIds());
lastContextSnapshot = buildMyWork.replaceIssueLabels(
lastContextSnapshot, editing.repository, editing.number, confirmed.labels
);
selectedIssue = { ...editing, labels: confirmed.labels };
qs('#issue-labels').innerHTML = confirmed.labels.map(label =>
'<span class="pill">' + escapeHtml(label) + '</span>'
).join(' ');
paintMyWork(lastContextSnapshot);
qs('#issue-label-status').textContent = 'Labels saved. My Work reprioritized.';
} catch (error) {
qs('#issue-label-status').textContent = error.message + ' Your selection is safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#send-issue-comment').addEventListener('click', async () => {
if (!selectedIssue) return;
const body = qs('#issue-comment').value.trim();
@@ -1216,6 +1719,27 @@ textarea { resize: vertical; min-height: 120px; }
button.disabled = false;
}
});
qs('#release-issue').addEventListener('click', async () => {
if (!selectedIssue || !window.confirm('Release ' + selectedIssue.key + ' from your My Work?')) return;
const releasing = selectedIssue;
const button = qs('#release-issue');
button.disabled = true;
qs('#issue-sheet-status').textContent = 'Releasing assignment…';
try {
const confirmed = await issueController.release(selectedIssue, lastContextSnapshot?.user?.login);
lastContextSnapshot = buildMyWork.removeIssue(
lastContextSnapshot, releasing.repository, releasing.number
);
paintMyWork(lastContextSnapshot);
closeIssueSheet();
qs('#my-work-action-status').textContent = releasing.key + ' released.' +
(confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.');
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#close-issue').addEventListener('click', async () => {
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
const closing = selectedIssue;
@@ -1236,6 +1760,57 @@ textarea { resize: vertical; min-height: 120px; }
button.focus();
}
});
qs('#close-pull-sheet').addEventListener('click', closePullSheet);
qs('#retry-pull-load').addEventListener('click', () => {
if (selectedPull) openPullSheet(selectedPull, pullTrigger);
});
qs('#pull-comment').addEventListener('input', event => {
if (selectedPull) pullController.saveDraft(selectedPull, event.target.value);
});
qs('#send-pull-comment').addEventListener('click', async () => {
if (!selectedPull) return;
const body = qs('#pull-comment').value.trim();
if (!body) {
qs('#pull-comment-status').textContent = 'Write a comment before posting.';
qs('#pull-comment').focus();
return;
}
const button = qs('#send-pull-comment');
button.disabled = true;
qs('#pull-comment-status').textContent = 'Posting comment…';
try {
const comment = await pullController.comment(selectedPull, body);
qs('#pull-comments .muted')?.remove();
qs('#pull-comments').insertAdjacentHTML('beforeend', '<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>');
qs('#pull-comment').value = '';
qs('#pull-comment-status').textContent = 'Comment posted.';
} catch (error) {
qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#pull-comment').focus();
} finally {
button.disabled = false;
}
});
qs('#merge-pull').addEventListener('click', async () => {
if (!selectedPull || !selectedPullDetail?.head_sha || !window.confirm('Merge ' + selectedPull.key + ' at current head?')) return;
const merging = selectedPull;
const button = qs('#merge-pull');
button.disabled = true;
qs('#pull-sheet-status').textContent = 'Merging pull request…';
try {
await pullController.merge(selectedPull, selectedPullDetail.head_sha);
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
);
closePullSheet();
refreshMyWorkView();
qs('#my-work-action-status').textContent = merging.key + ' merged.';
} catch (error) {
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; refresh and 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);
@@ -1278,6 +1853,32 @@ textarea { resize: vertical; min-height: 120px; }
});
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
qs('#save-inline-comment').addEventListener('click', () => {
if (!draft || !activeInlineTarget) return;
const body = qs('#review-inline-body').value.trim();
if (!body) {
qs('#review-inline-body').focus();
return;
}
draft.setInlineComment(inlineAnchor(activeInlineTarget), body);
activeInlineTarget.classList.add('has-draft');
const target = activeInlineTarget;
closeInlineComposer();
target.focus();
});
qs('#delete-inline-comment').addEventListener('click', () => {
if (!draft || !activeInlineTarget) return;
draft.removeInlineComment(inlineAnchor(activeInlineTarget));
activeInlineTarget.classList.remove('has-draft');
const target = activeInlineTarget;
closeInlineComposer();
target.focus();
});
qs('#cancel-inline-comment').addEventListener('click', () => {
const target = activeInlineTarget;
closeInlineComposer();
target?.focus();
});
qs('#submit-review').addEventListener('click', async () => {
if (!draft || !selectedReview || !selectedReviewHead) return;
const snapshot = draft.snapshot();
@@ -1293,6 +1894,7 @@ textarea { resize: vertical; min-height: 120px; }
decision: snapshot.decision,
body: createReviewController.formatFeedback(snapshot, reviewFiles),
expected_head_sha: selectedReviewHead,
comments: snapshot.comments,
});
draft.clear();
progress?.clear();
@@ -1365,6 +1967,21 @@ textarea { resize: vertical; min-height: 120px; }
qs('#load-more-notifications').addEventListener('click', () =>
notificationPager.loadMore(lastNotifications)
);
qs('#load-more-work').addEventListener('click', async () => {
const stream = activeWorkStreams().find(item => workPagination[item]?.has_more);
if (!stream || !lastContextSnapshot) return;
const button = qs('#load-more-work');
button.disabled = true;
const existing = stream === 'issue' ?
(lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []);
try {
const loaded = await workPager.loadMore(stream, existing);
if (loaded) document.querySelector('.my-work-card:last-child .my-work-card-main')?.focus();
else button.focus();
} finally {
button.disabled = false;
}
});
qs('#bulk-mark-read').addEventListener('click', async () => {
const allIds = notificationIds(filterMyWork(lastMyWork, 'update'));
const ids = allIds.slice(0, 50);
@@ -1400,6 +2017,7 @@ textarea { resize: vertical; min-height: 120px; }
item.setAttribute('aria-pressed', String(item === button))
);
renderMyWork();
updateWorkPaginationControls();
});
});
contextPoller.start();
+49
View File
@@ -1,9 +1,12 @@
function createIssueSheet({ fetchJson, storage }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
let labelRequest = 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;
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
return {
load(item) {
@@ -11,6 +14,11 @@ function createIssueSheet({ fetchJson, storage }) {
headers: { Accept: 'application/json' },
});
},
loadLabels(item) {
return fetchJson(issuePath(item) + '/labels', {
headers: { Accept: 'application/json' },
});
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
@@ -19,6 +27,30 @@ function createIssueSheet({ fetchJson, storage }) {
try { storage?.setItem(draftKey(item), body); }
catch (_error) { /* The textarea remains the fallback. */ }
},
loadLabelDraft(item) {
try {
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
return Array.isArray(value) ? value.filter(Number.isInteger) : [];
} catch (_error) { return []; }
},
updateLabels(item, labelIds) {
if (labelRequest) return labelRequest;
try { storage?.setItem(labelDraftKey(item), JSON.stringify(labelIds)); }
catch (_error) { /* The checked controls remain the fallback. */ }
labelRequest = fetchJson(issuePath(item) + '/labels', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ label_ids: labelIds }),
}).then(result => {
if (result?.number !== item.number || !Array.isArray(result.labels)) {
throw new Error('Issue labels were not confirmed.');
}
try { storage?.removeItem(labelDraftKey(item)); }
catch (_error) { /* The upstream label set is authoritative. */ }
return result;
}).finally(() => { labelRequest = null; });
return labelRequest;
},
close(item) {
if (closeRequest) return closeRequest;
closeRequest = fetchJson(issuePath(item) + '/close', {
@@ -30,6 +62,23 @@ function createIssueSheet({ fetchJson, storage }) {
}).finally(() => { closeRequest = null; });
return closeRequest;
},
release(item, currentLogin) {
if (releaseRequest) return releaseRequest;
releaseRequest = fetchJson(issuePath(item) + '/release', {
method: 'PATCH',
headers: { Accept: 'application/json' },
}).then(result => {
if (
result?.number !== item.number ||
!Array.isArray(result.assignees) ||
result.assignees.includes(currentLogin)
) {
throw new Error('Issue release was not confirmed.');
}
return result;
}).finally(() => { releaseRequest = null; });
return releaseRequest;
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
+81
View File
@@ -169,6 +169,66 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
};
}
function createWorkPager({ load, onItems, onPagination, onStatus }) {
let pagination = {};
const pending = new Set();
const labels = {
issue: 'issues',
pull: 'pull requests',
review: 'review requests',
};
return {
reset(next) {
Object.entries(next || {}).forEach(([stream, value]) => {
const current = pagination[stream];
pagination[stream] = current && current.page > 1 ?
{ ...value, page: current.page, has_more: current.page * 50 < value.total } :
{ ...value };
});
onPagination({ ...pagination });
},
async loadMore(stream, existing) {
const page = pagination[stream];
if (pending.has(stream) || !page?.has_more) return false;
pending.add(stream);
const label = labels[stream] || 'work';
onStatus('Loading older ' + label + '…');
try {
const result = await load(stream, page.page + 1);
const merged = 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)) return;
const current = merged.get(item.id);
if (!current) {
merged.set(item.id, { ...item });
return;
}
const reasons = Array.from(new Set(
(current.work_reasons || []).concat(item.work_reasons || [])
));
merged.set(item.id, {
...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}),
});
});
pagination[stream] = {
page: result.page, total: result.total, has_more: result.has_more === true,
};
onItems(stream, Array.from(merged.values()));
onPagination({ ...pagination });
onStatus(Math.min(result.total, result.page * 50) + ' of ' + result.total + ' ' + label + ' loaded.');
return true;
} catch (_error) {
onStatus('Could not load older ' + label + '. Retry.');
return false;
} finally {
pending.delete(stream);
}
},
};
}
function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose }) {
let selected = null;
let loadVersion = 0;
@@ -266,6 +326,24 @@ function filterMyWork(items, selectedFilter) {
return items.filter((item) => item.kind === selectedFilter);
}
function replaceIssueLabels(data, repository, number, labels) {
return {
...data,
issues: (data.issues || []).map(item =>
item.repository === repository && item.number === number ? { ...item, labels: [...labels] } : item
),
};
}
function removeIssue(data, repository, number) {
return {
...data,
issues: (data.issues || []).filter(item =>
item.repository !== repository || item.number !== number
),
};
}
function summarizeMyWork(items) {
const updates = items.filter((item) => item.has_update).length;
const reviews = items.filter((item) => item.is_review).length;
@@ -288,6 +366,8 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.removeIssue = removeIssue;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
@@ -295,6 +375,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.notificationIds = notificationIds;
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
buildMyWork.createNotificationPager = createNotificationPager;
buildMyWork.createWorkPager = createWorkPager;
buildMyWork.createNotificationReader = createNotificationReader;
buildMyWork.createNotificationReplier = createNotificationReplier;
module.exports = buildMyWork;
+99
View File
@@ -0,0 +1,99 @@
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
let available = [];
let pagination = { page: 1, total: 0, has_more: false };
let loadRequest = null;
let claimRequest = null;
const previewed = new Set();
function itemKey(item) {
return String(item?.repository || '') + '#' + String(item?.number || '');
}
function apply(result, append) {
const incoming = Array.isArray(result?.items) ? result.items : [];
if (append) {
const byId = new Map(available.map(item => [item.id, item]));
incoming.forEach(item => {
if (item && !byId.has(item.id)) byId.set(item.id, { ...item });
});
available = Array.from(byId.values());
} else {
available = incoming.map(item => ({ ...item }));
}
const availableKeys = new Set(available.map(itemKey));
previewed.forEach(key => {
if (!availableKeys.has(key)) previewed.delete(key);
});
pagination = {
page: Number(result?.page) || 1,
total: Number(result?.total) || available.length,
has_more: result?.has_more === true,
};
onItems(available.slice());
onPagination({ ...pagination });
}
function loadPage(page, append) {
if (loadRequest) return loadRequest;
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
headers: { Accept: 'application/json' },
}).then(result => {
apply(result, append);
return result;
}).finally(() => { loadRequest = null; });
return loadRequest;
}
return {
reset(result) {
apply(result, false);
},
load() {
return loadPage(1, false);
},
loadMore() {
if (!pagination.has_more) return Promise.resolve(false);
return loadPage(pagination.page + 1, true);
},
items() {
return available.slice();
},
togglePreview(item) {
const key = itemKey(item);
if (previewed.has(key)) previewed.delete(key);
else previewed.add(key);
return previewed.has(key);
},
isPreviewed(item) {
return previewed.has(itemKey(item));
},
claim(item) {
if (claimRequest) return claimRequest;
const key = item.repository + '#' + item.number;
onStatus('Assigning ' + key + '…');
const path = 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number) + '/claim';
claimRequest = fetchJson(path, {
method: 'PATCH',
headers: { Accept: 'application/json' },
}).then(confirmed => {
if (!Array.isArray(confirmed?.assignees) || !confirmed.assignees.length) {
throw new Error('Issue assignment was not confirmed.');
}
available = available.filter(candidate =>
candidate.repository !== item.repository || candidate.number !== item.number
);
pagination.total = Math.max(available.length, pagination.total - 1);
pagination.has_more = available.length < pagination.total;
previewed.delete(itemKey(item));
onItems(available.slice());
onPagination({ ...pagination });
onStatus('Assigned ' + key + ' to you.');
return confirmed;
}).finally(() => { claimRequest = null; });
return claimRequest;
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
+63
View File
@@ -0,0 +1,63 @@
function mergeEligibility(detail) {
if (!detail || detail.state !== 'open' || detail.merged) {
return { allowed: false, reason: 'Pull request is not open' };
}
if (detail.draft) return { allowed: false, reason: 'Draft pull requests cannot be merged' };
if (!detail.mergeable) return { allowed: false, reason: 'Resolve conflicts before merging' };
if (detail.ci_state !== 'success') {
return { allowed: false, reason: 'CI must succeed before merging' };
}
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
return { allowed: true, reason: 'Ready to merge' };
}
function createPullSheet({ fetchJson, storage }) {
let commentRequest = null;
let mergeRequest = null;
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
return {
load(item) {
return fetchJson(pathFor(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. */ }
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
commentRequest = fetchJson(pathFor(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;
},
merge(item, expectedHeadSha) {
if (mergeRequest) return mergeRequest;
mergeRequest = fetchJson(pathFor(item) + '/merge', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
}).then(result => {
if (!result?.merged) throw new Error('Pull request merge was not confirmed.');
return result;
}).finally(() => { mergeRequest = null; });
return mergeRequest;
},
};
}
createPullSheet.mergeEligibility = mergeEligibility;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
+94 -11
View File
@@ -34,14 +34,54 @@ function diffLineClass(line) {
return 'context';
}
function parseDiffLines(lines) {
let oldLine = null;
let newLine = null;
return (lines || []).map(value => {
const text = String(value);
const hunk = text.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (hunk) {
oldLine = Number(hunk[1]);
newLine = Number(hunk[2]);
return { text, kind: 'hunk', commentable: false };
}
if (text.startsWith('\\')) return { text, kind: 'note', commentable: false };
if (oldLine === null || newLine === null) {
return { text, kind: diffLineClass(text), commentable: false };
}
if (text.startsWith('-')) {
const row = { text, kind: 'removed', commentable: true, old_position: oldLine };
oldLine += 1;
return row;
}
if (text.startsWith('+')) {
const row = { text, kind: 'added', commentable: true, new_position: newLine };
newLine += 1;
return row;
}
const row = { text, kind: 'context', commentable: true, new_position: newLine };
oldLine += 1;
newLine += 1;
return row;
});
}
function renderDiffFile(file, index, escapeHtml) {
const panelId = 'review-diff-' + index;
let preview;
if (file.diff_available) {
const lines = (file.diff_lines || []).map(line =>
'<span class="review-diff-line ' + diffLineClass(String(line)) + '">' +
escapeHtml(String(line)) + '</span>'
).join('');
const lines = parseDiffLines(file.diff_lines).map(row => {
if (!row.commentable) {
return '<span class="review-diff-line ' + row.kind + '">' + escapeHtml(row.text) + '</span>';
}
const position = row.old_position
? ' data-old-position="' + row.old_position + '"'
: ' data-new-position="' + row.new_position + '"';
return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
'" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
(row.old_position || row.new_position) + '">' + escapeHtml(row.text) + '</button>';
}).join('');
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
'</pre>';
@@ -115,17 +155,27 @@ function createProgress({ storage, repository, number, headSha, files }) {
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' };
let draft = { notes: {}, comments: [], 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;
if (Array.isArray(saved.comments)) {
draft.comments = saved.comments.filter(comment =>
comment && filenames.includes(comment.path) && typeof comment.body === 'string' &&
Boolean(comment.body.trim()) &&
((Number.isInteger(comment.new_position) && !comment.old_position) ||
(Number.isInteger(comment.old_position) && !comment.new_position))
).map(comment => ({
path: comment.path, body: comment.body,
...(comment.new_position ? { new_position: comment.new_position } : {}),
...(comment.old_position ? { old_position: comment.old_position } : {}),
}));
}
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 */ }
@@ -134,7 +184,10 @@ function createDraft({ storage, repository, number, headSha, files }) {
}
function snapshot() {
return { notes: { ...draft.notes }, summary: draft.summary, decision: draft.decision };
return {
notes: { ...draft.notes }, comments: draft.comments.map(comment => ({ ...comment })),
summary: draft.summary, decision: draft.decision,
};
}
function setNote(filename, note) {
@@ -145,6 +198,35 @@ function createDraft({ storage, repository, number, headSha, files }) {
return snapshot();
}
function inlineKey(comment) {
return comment.path + ':' + (comment.new_position ? 'new:' + comment.new_position : 'old:' + comment.old_position);
}
function setInlineComment(anchor, body) {
if (!anchor || !filenames.includes(anchor.path)) return snapshot();
const position = Number.isInteger(anchor.new_position) && anchor.new_position > 0
? { new_position: anchor.new_position }
: Number.isInteger(anchor.old_position) && anchor.old_position > 0
? { old_position: anchor.old_position } : null;
const text = String(body || '').trim();
if (!position || !text) return snapshot();
const comment = { path: anchor.path, body: text, ...position };
const key = inlineKey(comment);
const index = draft.comments.findIndex(item => inlineKey(item) === key);
if (index >= 0) draft.comments[index] = comment;
else draft.comments.push(comment);
persist();
return snapshot();
}
function removeInlineComment(anchor) {
if (!anchor) return snapshot();
const key = inlineKey(anchor);
draft.comments = draft.comments.filter(comment => inlineKey(comment) !== key);
persist();
return snapshot();
}
function setSummary(summary) {
draft.summary = String(summary || '');
persist();
@@ -160,12 +242,12 @@ function createDraft({ storage, repository, number, headSha, files }) {
}
function clear() {
draft = { notes: {}, summary: '', decision: 'comment' };
draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
return snapshot();
}
return { snapshot, setNote, setSummary, setDecision, clear };
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear };
}
function formatFeedback(draft, files) {
@@ -208,6 +290,7 @@ async function copyAndContinue({ text, url, copy, open, fallback }) {
}
createReviewController.renderDiffFile = renderDiffFile;
createReviewController.parseDiffLines = parseDiffLines;
createReviewController.toggleDiff = toggleDiff;
createReviewController.createProgress = createProgress;
createReviewController.createDraft = createDraft;
+472 -33
View File
@@ -14,10 +14,34 @@ REVIEW_DIFF_MAX_LINES = 400
_client: httpx.AsyncClient | None = None
class WorkItems(list[dict]):
"""A list-compatible first page carrying truthful per-stream totals."""
def __init__(self, items: list[dict], pagination: dict[str, dict]):
super().__init__(items)
self.pagination = pagination
class StaleReviewError(ValueError):
"""Raised before mutation when a pull request head changed during review."""
class InvalidReviewCommentError(ValueError):
"""Raised before mutation when an inline comment cannot target this change."""
class StalePullError(ValueError):
"""Raised before merge when an assigned pull request head changed."""
class PullNotMergeableError(ValueError):
"""Raised before merge when current pull state or checks prohibit it."""
class IssueNotAvailableError(ValueError):
"""Raised before assignment when an issue is no longer open and unassigned."""
def _auth() -> dict[str, str]:
headers: dict[str, str] = {"Accept": "application/json"}
if GITEA_TOKEN:
@@ -117,10 +141,143 @@ async def repos() -> list[dict]:
return await fetch("user/repos?limit=50")
async def issues() -> list[dict]:
return await fetch(
"repos/issues/search?state=open&assigned=true&type=issues&limit=50"
WORK_SEARCHES = {
"issue": ("assigned=true", "issues", None),
"pull": ("assigned=true", "pulls", "assigned_to_me"),
"review": ("review_requested=true", "pulls", "review_requested"),
}
async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
"""Load exactly one bounded My Work stream page with upstream totals."""
selector, item_type, reason = WORK_SEARCHES[stream]
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
params={
"state": "open", selector.split("=", 1)[0]: "true",
"type": item_type, "limit": limit, "page": page,
},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea work search response was not a list")
items = [item for item in payload if isinstance(item, dict)]
if reason:
items = [{**item, "work_reasons": [reason]} for item in items]
try:
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
except (TypeError, ValueError):
total = len(items)
return {
"stream": stream,
"items": items,
"page": page,
"total": total,
"has_more": page * limit < total,
}
def _normalize_available_issue(item: Any) -> dict | None:
if (
not isinstance(item, dict)
or item.get("state") != "open"
or item.get("pull_request") is not None
or item.get("assignees") not in (None, [])
):
return None
labels_value = item.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
repository_value = item.get("repository")
repository = repository_value if isinstance(repository_value, dict) else {}
return {
"id": item.get("id"),
"number": item.get("number"),
"title": item.get("title", "") if isinstance(item.get("title"), str) else "",
"body": item.get("body", "") if isinstance(item.get("body"), str) else "",
"state": "open",
"repository": repository.get("full_name", "")
if isinstance(repository.get("full_name"), str) else "",
"labels": [
label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
"assignees": [],
"updated_at": item.get("updated_at", "")
if isinstance(item.get("updated_at"), str) else "",
"url": _safe_web_url(item.get("html_url")),
}
async def available_issue_snapshot(max_pages: int = 10, upstream_limit: int = 50) -> list[dict]:
"""Load, filter, and globally rank a bounded snapshot of available issues."""
items: list[dict] = []
seen_ids: set[Any] = set()
upstream_total: int | None = None
for upstream_page in range(1, max_pages + 1):
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
params={
"state": "open", "type": "issues",
"limit": upstream_limit, "page": upstream_page,
},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea available issue search response was not a list")
if upstream_total is None:
try:
upstream_total = max(0, int(response.headers["X-Total-Count"]))
except (KeyError, TypeError, ValueError):
upstream_total = None
for raw_item in payload:
item = _normalize_available_issue(raw_item)
if item is not None and item["id"] not in seen_ids:
seen_ids.add(item["id"])
items.append(item)
loaded = upstream_page * upstream_limit
if not payload or len(payload) < upstream_limit or (
upstream_total is not None and loaded >= upstream_total
):
break
priority = {"p0", "priority-high", "critical"}
items.sort(key=lambda item: (item["repository"], item["number"] or 0))
items.sort(key=lambda item: item["updated_at"], reverse=True)
items.sort(key=lambda item: (
0 if any(str(label).lower() in priority for label in item["labels"]) else 1
))
return items
async def available_issue_page(page: int = 1, limit: int = 50) -> dict:
"""Return one logical page from a bounded, globally ranked available-work scan."""
items = await available_issue_snapshot()
total = len(items)
start = (page - 1) * limit
page_items = items[start:start + limit]
return {
"items": page_items,
"page": page,
"total": total,
"has_more": start + len(page_items) < total,
}
def _page_metadata(result: dict) -> dict:
return {
"page": result["page"],
"total": result["total"],
"has_more": result["has_more"],
}
async def issues() -> WorkItems:
result = await work_page("issue")
return WorkItems(result["items"], {"issue": _page_metadata(result)})
def _safe_web_url(value: Any) -> str:
@@ -364,11 +521,46 @@ async def comment_on_issue(repository: str, number: int, body: str) -> dict:
return _normalize_issue_comment(comment)
async def create_issue(repository: str, title: str, body: str, assignee: str) -> dict:
async def repo_labels(repository: str) -> list[dict]:
response = await _get_client().get(
f"/api/v1/repos/{repository}/labels",
headers=_auth(),
params={"limit": 50, "page": 1},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea labels response was not a list")
return [
{
"id": item["id"],
"name": item["name"],
"color": item.get("color", "") if isinstance(item.get("color"), str) else "",
"description": item.get("description", "")
if isinstance(item.get("description"), str)
else "",
}
for item in payload
if isinstance(item, dict)
and isinstance(item.get("id"), int)
and isinstance(item.get("name"), str)
]
async def create_issue(
repository: str,
title: str,
body: str,
assignee: str,
label_ids: list[int] | None = None,
) -> dict:
payload: dict = {"title": title, "body": body, "assignee": assignee}
if label_ids:
payload["labels"] = label_ids
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues",
headers=_auth(),
json={"title": title, "body": body, "assignee": assignee},
json=payload,
)
response.raise_for_status()
issue = response.json()
@@ -383,6 +575,8 @@ async def create_issue(repository: str, title: str, body: str, assignee: str) ->
]
if assignee not in confirmed_assignees:
raise ValueError("Gitea did not confirm issue self-assignment")
labels_value = issue.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
return {
"id": issue.get("id"),
"number": issue["number"],
@@ -393,7 +587,11 @@ async def create_issue(repository: str, title: str, body: str, assignee: str) ->
if isinstance(issue.get("state"), str)
else "",
"repository": repository,
"labels": [],
"labels": [
item["name"]
for item in labels
if isinstance(item, dict) and isinstance(item.get("name"), str)
],
"assignees": confirmed_assignees,
"updated_at": issue.get("updated_at", "")
if isinstance(issue.get("updated_at"), str)
@@ -402,6 +600,134 @@ async def create_issue(repository: str, title: str, body: str, assignee: str) ->
}
async def claim_available_issue(repository: str, number: int) -> dict:
issue = await fetch(f"repos/{repository}/issues/{number}")
user = await current_user()
assignees_value = issue.get("assignees") if isinstance(issue, dict) else None
if (
not isinstance(issue, dict)
or issue.get("state") != "open"
or issue.get("pull_request") is not None
or assignees_value not in (None, [])
):
raise IssueNotAvailableError("Issue is no longer available")
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("Gitea current user did not include a login")
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"assignee": login},
)
response.raise_for_status()
confirmed = response.json()
if not isinstance(confirmed, dict) or confirmed.get("number") != number:
raise ValueError("Gitea did not confirm issue assignment")
confirmed_assignees_value = confirmed.get("assignees")
confirmed_assignees = (
confirmed_assignees_value if isinstance(confirmed_assignees_value, list) else []
)
logins = [
assignee["login"] for assignee in confirmed_assignees
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
]
if login not in logins:
raise ValueError("Gitea did not confirm issue assignment")
labels_value = confirmed.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
return {
"id": confirmed.get("id"),
"number": number,
"title": confirmed.get("title", "")
if isinstance(confirmed.get("title"), str) else "",
"state": "open",
"repository": repository,
"labels": [
label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
"assignees": logins,
"updated_at": confirmed.get("updated_at", "")
if isinstance(confirmed.get("updated_at"), str) else "",
"url": _safe_web_url(confirmed.get("html_url")),
}
async def release_assigned_issue(repository: str, number: int) -> dict:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
)
assignees_value = issue.get("assignees")
if (
issue.get("state") != "open"
or issue.get("pull_request") is not None
or not _login_in_users(login, assignees_value)
):
raise IssueNotAvailableError("Issue is not assigned to the current user")
assignees = assignees_value if isinstance(assignees_value, list) else []
remaining = [
assignee["login"] for assignee in assignees
if isinstance(assignee, dict)
and isinstance(assignee.get("login"), str)
and assignee["login"] != login
]
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"assignees": remaining},
)
response.raise_for_status()
confirmed = response.json()
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
confirmed_assignees = [
assignee["login"] for assignee in confirmed_value
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
] if isinstance(confirmed_value, list) else []
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or login in confirmed_assignees
or set(confirmed_assignees) != set(remaining)
):
raise ValueError("Gitea did not confirm issue release")
return {
"number": number,
"repository": repository,
"state": confirmed.get("state", "open"),
"assignees": confirmed_assignees,
"available": not confirmed_assignees,
}
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"labels": label_ids},
)
response.raise_for_status()
issue = response.json()
if not isinstance(issue, dict) or issue.get("number") != number:
raise ValueError("Gitea did not confirm the label update")
labels_value = issue.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
confirmed_ids = {
item["id"] for item in labels
if isinstance(item, dict) and isinstance(item.get("id"), int)
}
if confirmed_ids != set(label_ids):
raise ValueError("Gitea did not confirm the requested label set")
return {
"number": number,
"labels": [
item["name"] for item in labels
if isinstance(item, dict) and isinstance(item.get("name"), str)
],
}
async def issue_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/issues/{number}"
issue, comments = await asyncio.gather(
@@ -438,50 +764,152 @@ async def issue_detail(repository: str, number: int) -> dict:
}
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 _current_login_and_target(path: str) -> tuple[str, dict]:
user, target = await asyncio.gather(current_user(), fetch(path))
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login or not isinstance(target, dict):
return "", {}
return login, target
def _login_in_users(login: str, value: object) -> bool:
return isinstance(value, list) and any(
isinstance(user, dict) and user.get("login") == login for user in value
)
async def pull_requests() -> list[dict]:
async def is_assigned_issue(repository: str, number: int) -> bool:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
)
return (
issue.get("state") == "open"
and not isinstance(issue.get("pull_request"), dict)
and _login_in_users(login, issue.get("assignees"))
)
async def pull_requests() -> WorkItems:
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"
),
work_page("pull"),
work_page("review"),
)
merged: dict[int, dict] = {}
for reason, pulls in (
("assigned_to_me", assigned or []),
("review_requested", review_requested or []),
for result in (
assigned,
review_requested,
):
for pull in pulls:
for pull in result["items"]:
identity = pull.get("id")
if identity not in merged:
merged[identity] = {**pull, "work_reasons": []}
merged[identity]["work_reasons"].append(reason)
return list(merged.values())
for reason in pull.get("work_reasons", []):
if reason not in merged[identity]["work_reasons"]:
merged[identity]["work_reasons"].append(reason)
return WorkItems(
list(merged.values()),
{
"pull": _page_metadata(assigned),
"review": _page_metadata(review_requested),
},
)
async def is_requested_review(repository: str, number: int) -> bool:
pulls = await fetch(
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50"
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
return any(
isinstance(pull, dict)
and pull.get("number") == number
and isinstance(pull.get("repository"), dict)
and pull["repository"].get("full_name") == repository
for pull in (pulls or [])
return (
pull.get("state") == "open"
and _login_in_users(login, pull.get("requested_reviewers"))
)
async def is_assigned_pull(repository: str, number: int) -> bool:
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
return (
pull.get("state") == "open"
and _login_in_users(login, pull.get("assignees"))
)
async def pull_completion_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
sha = head.get("sha") if isinstance(head.get("sha"), str) else ""
files, status, comments = await asyncio.gather(
fetch(f"{base}/files"),
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch(f"repos/{repository}/issues/{number}/comments?limit=20&page=1"),
)
user = pull.get("user") if isinstance(pull.get("user"), dict) else {}
return {
"repository": repository,
"number": number,
"title": pull.get("title") if isinstance(pull.get("title"), str) else "",
"body": pull.get("body") if isinstance(pull.get("body"), str) else "",
"url": _safe_web_url(pull.get("html_url")),
"author": user.get("login") if isinstance(user.get("login"), str) else "",
"head_sha": sha,
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
"draft": pull.get("draft") is True,
"mergeable": pull.get("mergeable") is True,
"merged": pull.get("merged") is True,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"files": [
{
"filename": item.get("filename", ""),
"status": item.get("status") or "changed",
"additions": item.get("additions") or 0,
"deletions": item.get("deletions") or 0,
}
for item in (files if isinstance(files, list) else [])[:100]
if isinstance(item, dict) and isinstance(item.get("filename"), str)
],
"comments": [
_normalize_issue_comment(item)
for item in (comments if isinstance(comments, list) else [])[:20]
if isinstance(item, dict)
],
}
async def merge_assigned_pull(
repository: str, number: int, expected_head_sha: str
) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if not isinstance(pull, dict):
raise PullNotMergeableError("Pull request state is unavailable")
head_value = pull.get("head")
head: dict = head_value if isinstance(head_value, dict) else {}
current_sha = head.get("sha")
if current_sha != expected_head_sha:
raise StalePullError("Pull request changed before merge")
status = await fetch(f"repos/{repository}/commits/{current_sha}/status")
ci_state = status.get("state") if isinstance(status, dict) else "unknown"
if (
pull.get("state") != "open"
or pull.get("draft") is True
or pull.get("mergeable") is not True
or pull.get("merged") is True
or ci_state != "success"
):
raise PullNotMergeableError("Pull request is not currently safe to merge")
response = await _get_client().post(
f"/api/v1/{base}/merge",
headers=_auth(),
json={"Do": "merge", "head_commit_id": current_sha},
)
response.raise_for_status()
return {"number": number, "merged": True, "state": "closed"}
async def pull_review_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
@@ -557,6 +985,7 @@ async def submit_pull_review(
expected_head_sha: str,
decision: str,
body: str,
comments: list[dict] | None = None,
) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
@@ -564,6 +993,15 @@ async def submit_pull_review(
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")
if comments:
files = await fetch(f"{base}/files")
changed_paths = {
item.get("filename")
for item in (files if isinstance(files, list) else [])
if isinstance(item, dict) and isinstance(item.get("filename"), str)
}
if any(comment.get("path") not in changed_paths for comment in comments):
raise InvalidReviewCommentError("Inline comment path is not in this pull request")
response = await _get_client().post(
f"/api/v1/{base}/reviews",
headers=_auth(),
@@ -575,6 +1013,7 @@ async def submit_pull_review(
"request_changes": "REQUEST_CHANGES",
}[decision],
"commit_id": current_sha,
**({"comments": comments} if comments else {}),
},
)
response.raise_for_status()
+627 -99
View File
@@ -1,13 +1,15 @@
import asyncio
import math
import time
from collections.abc import Awaitable
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, Literal
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt, field_validator
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
from src import gitea_proxy
from src.gitea_proxy import (
@@ -28,23 +30,27 @@ from src.views import router as frontend_router
@asynccontextmanager
async def lifespan(_app: FastAPI):
global _live_snapshot_task
global _live_snapshot_task, _available_issue_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
live_task = _live_snapshot_task
available_task = _available_issue_snapshot_task
for task in (live_task, available_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:
if _live_snapshot_task is live_task:
_live_snapshot_task = None
if _available_issue_snapshot_task is available_task:
_available_issue_snapshot_task = None
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
@@ -53,21 +59,40 @@ EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
ISSUE_ACTION_TIMEOUT_SECONDS = 5.0
ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS = 600.0
ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES = 256
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
WORK_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
AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS = 15.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
LIVE_SNAPSHOT_SECTIONS = ("context", "events", "notifications")
_live_section_created_at: dict[str, float | None] = {
section: None for section in LIVE_SNAPSHOT_SECTIONS
}
_live_section_failure_count: dict[str, int] = {
section: 0 for section in LIVE_SNAPSHOT_SECTIONS
}
_live_section_retry_at: dict[str, float | None] = {
section: None for section in LIVE_SNAPSHOT_SECTIONS
}
_live_snapshot_refreshing_sections: set[str] = set()
_read_notification_ids: set[int] = set()
_issue_creation_operations: dict[
str, tuple[tuple[Any, ...], asyncio.Task, float]
] = {}
_available_issue_snapshot_task: asyncio.Task | None = None
_available_issue_snapshot_value: list[dict] | None = None
_available_issue_snapshot_created_at: float | None = None
class ContextPayloadError(ValueError):
@@ -109,6 +134,7 @@ class IssueComment(BaseModel):
class IssueCreation(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
label_ids: list[int] = Field(default_factory=list, max_length=20)
@field_validator("title")
@classmethod
@@ -124,10 +150,36 @@ class IssueCreation(BaseModel):
return value.strip()
class IssueLabelUpdate(BaseModel):
label_ids: list[PositiveInt] = Field(max_length=20)
class PullReviewComment(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
body: str = Field(min_length=1, max_length=10_000)
new_position: PositiveInt | None = None
old_position: PositiveInt | None = None
@field_validator("path", "body")
@classmethod
def strip_comment_text(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("inline comment values must not be blank")
return value
@model_validator(mode="after")
def require_one_position(self):
if (self.new_position is None) == (self.old_position is None):
raise ValueError("inline comments require exactly one old or new position")
return self
class PullReviewSubmission(BaseModel):
decision: str
body: str = Field(max_length=10_000)
expected_head_sha: str
comments: list[PullReviewComment] = Field(default_factory=list, max_length=50)
@field_validator("decision")
@classmethod
@@ -137,6 +189,10 @@ class PullReviewSubmission(BaseModel):
return value
class PullMergeSubmission(BaseModel):
expected_head_sha: str = Field(min_length=1, max_length=128)
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
user_model = User(
id=user_data["id"],
@@ -180,7 +236,44 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
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()
payload = compute(user_model, repo_models, issue_models, pr_models).model_dump()
pagination = {}
pagination.update(getattr(issues_data, "pagination", {}))
pagination.update(getattr(prs_data, "pagination", {}))
if pagination:
payload["work_pagination"] = pagination
return payload
def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
if stream == "issue":
return [
Issue(
id=item["id"], number=item["number"], title=item["title"],
state=item["state"],
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
updated_at=item.get("updated_at") or "", url=item["html_url"],
).model_dump()
for item in items
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
]
reason = "assigned_to_me" if stream == "pull" else "review_requested"
return [
PullRequest(
id=item["id"], number=item["number"], title=item["title"],
state=item["state"],
user=item["user"].get("login", "") if isinstance(item.get("user"), dict) else "",
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
work_reasons=[reason],
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
updated_at=item.get("updated_at") or "", url=item["html_url"],
).model_dump()
for item in items
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
]
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
app.include_router(frontend_router)
@@ -189,15 +282,18 @@ app.include_router(frontend_router)
@app.middleware("http")
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live"} or (
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues"} or request.url.path.startswith("/api/v1/work/") 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
and ("/issues/" in request.url.path or "/pulls/" in request.url.path)
) or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/issues")
) or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/labels")
):
response.headers["Cache-Control"] = "no-store"
return response
@@ -293,6 +389,89 @@ async def context() -> JSONResponse:
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
@app.get("/api/v1/work/{stream}")
async def paged_work(
stream: Literal["issue", "pull", "review"],
page: int = Query(ge=2, le=100),
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.work_page(stream, page), timeout=WORK_PAGE_TIMEOUT_SECONDS
)
except Exception:
return JSONResponse(
{"error": "Work page is temporarily unavailable"},
status_code=503,
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
)
return JSONResponse({
"stream": stream,
"page": result["page"],
"total": result["total"],
"has_more": result["has_more"],
"items": _normalize_work_items(stream, result["items"]),
})
async def _refresh_available_issue_snapshot() -> list[dict]:
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
result = await gitea_proxy.available_issue_snapshot()
_available_issue_snapshot_value = result
_available_issue_snapshot_created_at = time.monotonic()
return result
async def _available_issue_snapshot() -> tuple[list[dict], bool]:
global _available_issue_snapshot_task
now = time.monotonic()
if (
_available_issue_snapshot_value is not None
and _available_issue_snapshot_created_at is not None
and now - _available_issue_snapshot_created_at
< AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS
):
return _available_issue_snapshot_value, False
if _available_issue_snapshot_task is None or _available_issue_snapshot_task.done():
_available_issue_snapshot_task = asyncio.create_task(
_refresh_available_issue_snapshot()
)
try:
return await asyncio.shield(_available_issue_snapshot_task), False
except Exception:
if _available_issue_snapshot_value is not None:
return _available_issue_snapshot_value, True
raise
def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict:
start = (page - 1) * limit
page_items = items[start:start + limit]
return {
"items": page_items,
"page": page,
"total": len(items),
"has_more": start + len(page_items) < len(items),
}
@app.get("/api/v1/available-issues")
async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse:
try:
items, stale = await asyncio.wait_for(
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
)
except Exception:
return JSONResponse(
{"error": "Available issues are temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
)
result = _available_issue_page(items, page)
if stale:
result["stale"] = True
return JSONResponse(result)
async def _load_context_for_user(user_data: dict) -> dict:
repo_data, issues_data, prs_data = await asyncio.gather(
repos(), issues(), pull_requests()
@@ -307,19 +486,41 @@ async def _load_context_for_user(user_data: dict) -> dict:
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,
async def _build_live_snapshot(sections: set[str] | None = None) -> dict:
requested = set(sections or LIVE_SNAPSHOT_SECTIONS)
results: dict[str, object] = {}
user_data: dict | None = None
if requested & {"context", "events"}:
try:
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")
except Exception as exc:
for section in requested & {"context", "events"}:
results[section] = exc
loads: dict[str, Awaitable[Any]] = {}
if "context" in requested and "context" not in results:
assert user_data is not None
loads["context"] = _load_context_for_user(user_data)
if "events" in requested and "events" not in results:
assert user_data is not None
loads["events"] = activity_events(user_data)
if "notifications" in requested:
loads["notifications"] = notifications()
if loads:
loaded = await asyncio.gather(*loads.values(), return_exceptions=True)
results.update(zip(loads, loaded))
context_result = results.get("context")
events_result = results.get("events")
notifications_result = results.get("notifications")
context_ok = "context" in requested and not isinstance(context_result, BaseException)
events_ok = "events" in requested and not isinstance(events_result, BaseException)
notifications_ok = (
"notifications" in requested
and not isinstance(notifications_result, BaseException)
)
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):
@@ -336,64 +537,88 @@ async def _build_live_snapshot() -> dict:
"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",
section: "fresh" if ok else "temporarily unavailable"
for section, ok in (
("context", context_ok),
("events", events_ok),
("notifications", notifications_ok),
)
if section in requested
},
}
async def _build_live_snapshot_before_deadline() -> dict:
async def _build_live_snapshot_before_deadline(sections: set[str]) -> dict:
async with asyncio.timeout(CONTEXT_TIMEOUT_SECONDS):
return await _build_live_snapshot()
return await _build_live_snapshot(sections)
def _record_live_snapshot_failure() -> None:
global _live_snapshot_failure_count, _live_snapshot_retry_at
_live_snapshot_failure_count += 1
def _record_live_section_failure(section: str) -> None:
_live_section_failure_count[section] += 1
delay = min(
LIVE_SNAPSHOT_RETRY_MAX_SECONDS,
LIVE_SNAPSHOT_RETRY_BASE_SECONDS * (2 ** (_live_snapshot_failure_count - 1)),
LIVE_SNAPSHOT_RETRY_BASE_SECONDS
* (2 ** (_live_section_failure_count[section] - 1)),
)
_live_snapshot_retry_at = time.monotonic() + delay
_live_section_retry_at[section] = time.monotonic() + delay
def _due_live_sections(now: float) -> set[str]:
due = set()
for section in LIVE_SNAPSHOT_SECTIONS:
retry_at = _live_section_retry_at[section]
if retry_at is not None and now < retry_at:
continue
created_at = _live_section_created_at[section]
if created_at is None or now - created_at >= LIVE_SNAPSHOT_FRESHNESS_SECONDS:
due.add(section)
return due
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"
merged = dict(previous)
sections = dict(previous.get("sections") or {})
for section, state in (refreshed.get("sections") or {}).items():
if state == "fresh":
merged[section] = refreshed.get(section)
sections[section] = "fresh"
if section == "notifications":
merged["notification_pagination"] = previous.get(
merged["notification_pagination"] = refreshed.get(
"notification_pagination"
)
elif previous.get(section) is not None:
sections[section] = "stale"
else:
merged[section] = None
sections[section] = "temporarily unavailable"
merged["sections"] = sections
return merged
async def _refresh_live_snapshot() -> dict:
async def _refresh_live_snapshot(sections: set[str]) -> 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()
refreshed = await _build_live_snapshot_before_deadline(sections)
except Exception:
_record_live_snapshot_failure()
for section in sections:
_record_live_section_failure(section)
raise
result = _merge_live_snapshot(_live_snapshot_value, result)
now = time.monotonic()
refreshed_states = refreshed.get("sections") or {}
for section in sections:
if refreshed_states.get(section) == "fresh":
_live_section_created_at[section] = now
_live_section_failure_count[section] = 0
_live_section_retry_at[section] = None
else:
_record_live_section_failure(section)
result = _merge_live_snapshot(_live_snapshot_value, refreshed)
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
successful_times = [value for value in _live_section_created_at.values() if value is not None]
_live_snapshot_created_at = max(successful_times) if successful_times else None
return result
@@ -403,34 +628,47 @@ def _consume_live_snapshot_failure(task: asyncio.Task) -> None:
task.exception()
def _start_live_snapshot_refresh() -> asyncio.Task:
def _start_live_snapshot_refresh(sections: set[str]) -> asyncio.Task:
global _live_snapshot_task
global _live_snapshot_refreshing_sections
if _live_snapshot_task is None or _live_snapshot_task.done():
_live_snapshot_task = asyncio.create_task(_refresh_live_snapshot())
_live_snapshot_refreshing_sections = set(sections)
_live_snapshot_task = asyncio.create_task(_refresh_live_snapshot(sections))
_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
)
now = time.monotonic()
section_freshness = {}
for section in LIVE_SNAPSHOT_SECTIONS:
created_at = _live_section_created_at[section]
retry_at = _live_section_retry_at[section]
age = max(0.0, now - created_at) if created_at is not None else 0.0
retry_in = max(0.0, retry_at - now) if retry_at is not None else 0.0
section_freshness[section] = {
"age_seconds": round(age, 3),
"stale": (value.get("sections") or {}).get(section) != "fresh"
or created_at is None
or age >= LIVE_SNAPSHOT_FRESHNESS_SECONDS,
"revalidating": revalidating
and section in _live_snapshot_refreshing_sections,
"degraded": retry_at is not None,
"retry_in_seconds": math.ceil(retry_in),
}
ages = [item["age_seconds"] for item in section_freshness.values()]
retries = [item["retry_in_seconds"] for item in section_freshness.values() if item["retry_in_seconds"]]
degraded = any(item["degraded"] for item in section_freshness.values())
payload["freshness"] = {
"age_seconds": round(age, 3),
"age_seconds": max(ages, default=0.0),
"fresh_for_seconds": LIVE_SNAPSHOT_FRESHNESS_SECONDS,
"stale": stale,
"stale": stale or any(item["stale"] for item in section_freshness.values()),
"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),
"degraded": degraded,
"last_refresh_failed": degraded,
"retry_in_seconds": min(retries, default=0),
"sections": section_freshness,
}
return payload
@@ -478,33 +716,22 @@ 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()
due_sections = _due_live_sections(now)
if (
_live_snapshot_value is not None
and _live_snapshot_retry_at is not None
):
if now < _live_snapshot_retry_at:
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value, stale=True, revalidating=False
)
)
_start_live_snapshot_refresh()
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value, stale=True, revalidating=True
)
)
if (
_live_snapshot_value is not None
and _live_snapshot_created_at is not None
and now - _live_snapshot_created_at < LIVE_SNAPSHOT_FRESHNESS_SECONDS
and not due_sections
):
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value, stale=False, revalidating=False
_live_snapshot_value,
stale=any(
state != "fresh"
for state in _live_snapshot_value.get("sections", {}).values()
),
revalidating=False,
)
)
task = _start_live_snapshot_refresh()
task = _start_live_snapshot_refresh(due_sections)
if _live_snapshot_value is not None:
return JSONResponse(
_live_snapshot_payload(
@@ -701,6 +928,62 @@ async def reply_to_notification(
return JSONResponse(result, status_code=201)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/claim")
async def claim_available_issue(
owner: str, repo: str, number: int = PathParam(gt=0)
) -> JSONResponse:
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.claim_available_issue(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "This issue was already claimed or is no longer open. Refresh Find Work."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The issue could not be assigned. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
if _available_issue_snapshot_value is not None:
_available_issue_snapshot_value[:] = [
item for item in _available_issue_snapshot_value
if not (
item.get("repository") == repository and item.get("number") == number
)
]
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/release")
async def release_assigned_issue(
owner: str, repo: str, number: int = PathParam(gt=0)
) -> JSONResponse:
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.release_assigned_issue(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
raise HTTPException(status_code=404, detail="Assigned issue not found")
except Exception:
return JSONResponse(
{"error": "The assignment could not be released. It remains in My Work; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
if result.get("available"):
_available_issue_snapshot_value = None
_available_issue_snapshot_created_at = None
return JSONResponse(result)
@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}"
@@ -730,8 +1013,40 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g
)
@app.get("/api/v1/repos/{owner}/{repo}/labels")
async def repository_labels(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_labels():
available = await gitea_proxy.repos()
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if repository not in accessible:
raise HTTPException(status_code=404, detail="Repository not found")
return await gitea_proxy.repo_labels(repository)
try:
return await asyncio.wait_for(load_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Labels could not be loaded. Issue creation is still available."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201)
async def create_assigned_issue(creation: IssueCreation, owner: str, repo: str):
async def create_assigned_issue(
creation: IssueCreation,
owner: str,
repo: str,
idempotency_key: str | None = Header(default=None, max_length=128),
):
repository = f"{owner}/{repo}"
async def create_issue():
@@ -746,12 +1061,61 @@ async def create_assigned_issue(creation: IssueCreation, owner: str, repo: str):
}
if not login or repository not in accessible:
raise HTTPException(status_code=404, detail="Repository not found")
if creation.label_ids:
available_labels = await gitea_proxy.repo_labels(repository)
valid_label_ids = {
item.get("id")
for item in available_labels
if isinstance(item, dict) and isinstance(item.get("id"), int)
}
if any(label_id not in valid_label_ids for label_id in creation.label_ids):
raise HTTPException(status_code=422, detail="Unknown repository label")
return await gitea_proxy.create_issue(
repository, creation.title, creation.body, login
repository, creation.title, creation.body, login, creation.label_ids
)
operation = create_issue()
if idempotency_key:
now = time.monotonic()
expired = [
key for key, (_, task, created_at) in _issue_creation_operations.items()
if task.done()
and now - created_at >= ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS
]
for key in expired:
_issue_creation_operations.pop(key, None)
fingerprint = (
repository, creation.title, creation.body, tuple(creation.label_ids)
)
existing = _issue_creation_operations.get(idempotency_key)
if existing is not None:
operation.close()
if existing[0] != fingerprint:
raise HTTPException(status_code=409, detail="Idempotency key already used")
task = existing[1]
else:
while len(_issue_creation_operations) >= ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES:
completed = [
key for key, (_, task, _) in _issue_creation_operations.items()
if task.done()
]
if not completed:
operation.close()
raise HTTPException(
status_code=503,
detail="Issue creation is busy; please retry",
headers={"Retry-After": "1"},
)
oldest = min(
completed, key=lambda key: _issue_creation_operations[key][2]
)
_issue_creation_operations.pop(oldest)
task = asyncio.create_task(operation)
_issue_creation_operations[idempotency_key] = (fingerprint, task, now)
operation = asyncio.shield(task)
try:
result = await asyncio.wait_for(create_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
result = await asyncio.wait_for(operation, timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
except HTTPException:
raise
except Exception:
@@ -808,6 +1172,64 @@ async def close_assigned_issue(owner: str, repo: str, number: int = PathParam(gt
)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/labels")
async def assigned_issue_label_options(
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
async def load_labels():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.repo_labels(repository)
try:
return await asyncio.wait_for(load_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Labels could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/labels")
async def update_assigned_issue_labels(
update: IssueLabelUpdate,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
async def update_labels():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
available = await gitea_proxy.repo_labels(repository)
valid_ids = {
item.get("id") for item in available
if isinstance(item, dict) and isinstance(item.get("id"), int)
}
if any(label_id not in valid_ids for label_id in update.label_ids):
raise HTTPException(status_code=422, detail="Unknown repository label")
return await gitea_proxy.update_issue_labels(repository, number, update.label_ids)
try:
return await asyncio.wait_for(update_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Labels could not be updated. Your selection is safe; 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():
@@ -839,6 +1261,101 @@ async def review_detail(owner: str, repo: str, number: int):
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/detail")
async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
repository = f"{owner}/{repo}"
async def load_assigned_pull():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.pull_completion_detail(repository, number)
try:
return await asyncio.wait_for(
load_assigned_pull(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except TimeoutError:
return JSONResponse(
{"error": "Loading the pull request timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The pull request is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", status_code=201)
async def comment_on_assigned_pull(
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_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request 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.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge")
async def merge_assigned_pull(
submission: PullMergeSubmission,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
async def merge_pull():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.merge_assigned_pull(
repository, number, submission.expected_head_sha
)
try:
return await asyncio.wait_for(
merge_pull(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except gitea_proxy.StalePullError:
return JSONResponse(
{"error": "New commits were pushed. Refresh before merging."},
status_code=409,
)
except gitea_proxy.PullNotMergeableError:
return JSONResponse(
{"error": "This pull request is not currently safe to merge. Refresh its status."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The pull request could not be merged. It remains in My Work; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@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
@@ -848,13 +1365,19 @@ async def submit_review(
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(
args = (
repository,
number,
submission.expected_head_sha,
submission.decision,
submission.body,
)
if submission.comments:
return await gitea_proxy.submit_pull_review(
*args,
[comment.model_dump() for comment in submission.comments],
)
return await gitea_proxy.submit_pull_review(*args)
try:
result = await asyncio.wait_for(
@@ -867,6 +1390,11 @@ async def submit_review(
{"error": "New commits were pushed. Refresh the review before submitting."},
status_code=409,
)
except gitea_proxy.InvalidReviewCommentError:
return JSONResponse(
{"error": "An inline comment no longer matches this pull request. Refresh the review."},
status_code=422,
)
except Exception:
return JSONResponse(
{"error": "The review could not be submitted. Your draft is safe; please retry."},
+1
View File
@@ -19,4 +19,5 @@ def test_api_requests_resolve_inside_dashboard_subpath():
"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",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
}
+35
View File
@@ -79,3 +79,38 @@ async def test_application_shutdown_finishes_snapshot_before_closing_transport(m
main._live_snapshot_task = None
assert calls == ["start", "snapshot cancelled", "stop (done)"]
@pytest.mark.anyio
async def test_application_shutdown_cancels_available_work_scan_before_transport(monkeypatch):
calls = []
started = asyncio.Event()
async def active_scan():
started.set()
try:
await asyncio.Event().wait()
finally:
calls.append("available scan cancelled")
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
async def stop_client():
task = main._available_issue_snapshot_task
calls.append(f"stop ({'done' if task is not None and task.done() else 'active'})")
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
try:
async with main.app.router.lifespan_context(main.app):
main._available_issue_snapshot_task = asyncio.create_task(active_scan())
await started.wait()
finally:
task = main._available_issue_snapshot_task
if task is not None and not task.done():
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
main._available_issue_snapshot_task = None
assert calls == ["start", "available scan cancelled", "stop (done)"]
+335 -39
View File
@@ -2,27 +2,276 @@ import asyncio
import json
import pytest
import httpx
from src import gitea_proxy
from src import main
@pytest.fixture(autouse=True)
def reset_available_issue_snapshot():
main._available_issue_snapshot_task = None
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
yield
task = main._available_issue_snapshot_task
if task is not None and not task.done():
task.cancel()
main._available_issue_snapshot_task = None
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
@pytest.mark.anyio
async def test_work_page_preserves_total_and_reason_without_loading_other_pages():
requests = []
def upstream(request):
requests.append(str(request.url))
return httpx.Response(
200,
headers={"X-Total-Count": "84"},
json=[{
"id": 51,
"number": 51,
"title": "Older review",
"state": "open",
"repository": {"full_name": "stackchain/api"},
"html_url": "https://forge.example/stackchain/api/pulls/51",
}],
)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.work_page("review", page=2)
finally:
await gitea_proxy.stop_client()
assert requests == [
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&review_requested=true&type=pulls&limit=50&page=2"
]
assert result["page"] == 2
assert result["total"] == 84
assert result["has_more"] is False
assert result["items"][0]["work_reasons"] == ["review_requested"]
@pytest.mark.anyio
async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_priority():
requests = []
def upstream(request):
requests.append(str(request.url))
return httpx.Response(
200,
headers={"X-Total-Count": "4"},
json=[
{"id": 1, "number": 1, "title": "Ordinary", "state": "open",
"updated_at": "2026-08-07T12:00:00Z", "assignees": None,
"pull_request": None,
"labels": [], "repository": {"full_name": "stackchain/api"}},
{"id": 2, "number": 2, "title": "Claimed", "state": "open",
"assignees": [{"login": "alex"}], "repository": {"full_name": "stackchain/api"}},
{"id": 3, "number": 3, "title": "A pull", "state": "open",
"assignees": [], "pull_request": {"merged": False},
"repository": {"full_name": "stackchain/api"}},
{"id": 4, "number": 4, "title": "Critical", "state": "open",
"updated_at": "2026-08-07T10:00:00Z", "assignees": [],
"pull_request": None,
"labels": [{"name": "critical"}],
"repository": {"full_name": "stackchain/web"}},
],
)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.available_issue_page(page=1)
finally:
await gitea_proxy.stop_client()
assert requests == [
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&type=issues&limit=50&page=1"
]
assert [item["title"] for item in result["items"]] == ["Critical", "Ordinary"]
assert result == {
"items": result["items"], "page": 1, "total": 2, "has_more": False,
}
@pytest.mark.anyio
async def test_available_issue_page_ranks_all_upstream_pages_before_logical_pagination():
requests = []
def issue(number, *, assigned=True, critical=False, updated_at="2026-08-07T12:00:00Z"):
return {
"id": number, "number": number, "title": f"Issue {number}", "state": "open",
"updated_at": updated_at, "assignees": [{"login": "alex"}] if assigned else None,
"pull_request": None,
"labels": [{"name": "critical"}] if critical else [],
"repository": {"full_name": "stackchain/api"},
}
first_page = [issue(number) for number in range(1, 51)]
first_page[0] = issue(1, assigned=False, updated_at="2026-08-07T13:00:00Z")
def upstream(request):
requests.append(str(request.url))
page = int(request.url.params["page"])
payload = first_page if page == 1 else [
issue(51, assigned=False, critical=True, updated_at="2026-08-07T10:00:00Z"),
issue(52, assigned=False, updated_at="2026-08-07T11:00:00Z"),
]
return httpx.Response(200, headers={"X-Total-Count": "52"}, json=payload)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.available_issue_page(page=1, limit=2)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 2
assert [item["number"] for item in result["items"]] == [51, 1]
assert result == {
"items": result["items"], "page": 1, "total": 3, "has_more": True,
}
@pytest.mark.anyio
async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkeypatch):
calls = []
async def available():
calls.append(True)
return [{"number": number} for number in range(1, 52)]
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/available-issues?page=1")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"items": [{"number": number} for number in range(1, 51)],
"page": 1, "total": 51, "has_more": True,
}
assert calls == [True]
@pytest.mark.anyio
async def test_available_issue_endpoint_coalesces_cold_scan_and_reuses_it_for_pages(monkeypatch):
calls = 0
started = asyncio.Event()
release = asyncio.Event()
async def available():
nonlocal calls
calls += 1
started.set()
await release.wait()
return [{"number": number} for number in range(1, 64)]
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = asyncio.create_task(client.get("/api/v1/available-issues?page=1"))
second = asyncio.create_task(client.get("/api/v1/available-issues?page=2"))
await asyncio.wait_for(started.wait(), timeout=1)
release.set()
first_response, second_response = await asyncio.gather(first, second)
repeated_response = await client.get("/api/v1/available-issues?page=1")
assert calls == 1
assert len(first_response.json()["items"]) == 50
assert len(second_response.json()["items"]) == 13
assert second_response.json()["total"] == 63
assert second_response.json()["has_more"] is False
assert repeated_response.status_code == 200
@pytest.mark.anyio
async def test_available_issue_endpoint_retains_last_snapshot_on_refresh_failure(monkeypatch):
async def unavailable():
raise httpx.ConnectError("offline")
main._available_issue_snapshot_value = [{"number": 7}]
main._available_issue_snapshot_created_at = 0.0
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", unavailable)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/available-issues?page=1")
assert response.status_code == 200
assert response.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True,
}
@pytest.mark.anyio
async def test_confirmed_claim_is_removed_from_retained_available_snapshot(monkeypatch):
main._available_issue_snapshot_value = [
{"number": 7, "repository": "stackchain/api"},
{"number": 8, "repository": "stackchain/web"},
]
main._available_issue_snapshot_created_at = 10**12
async def claim(repository, number):
return {"repository": repository, "number": number, "assignees": ["timmy"]}
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
claimed = await client.patch("/api/v1/repos/stackchain/api/issues/7/claim")
available = await client.get("/api/v1/available-issues?page=1")
assert claimed.status_code == 200
assert available.json() == {
"items": [{"number": 8, "repository": "stackchain/web"}],
"page": 1, "total": 1, "has_more": False,
}
@pytest.mark.anyio
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
async def fake_page(stream, page=1, limit=50):
assert page == 1
totals = {"issue": 84, "pull": 61, "review": 73}
return {
"items": [], "page": 1, "total": totals[stream],
"has_more": True, "stream": stream,
}
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
assigned_issues = await gitea_proxy.issues()
pulls = await gitea_proxy.pull_requests()
assert assigned_issues.pagination == {
"issue": {"page": 1, "total": 84, "has_more": True}
}
assert pulls.pagination == {
"pull": {"page": 1, "total": 61, "has_more": True},
"review": {"page": 1, "total": 73, "has_more": True},
}
@pytest.mark.anyio
async def test_work_collections_include_supported_review_request_search(monkeypatch):
requested_paths = []
requested_streams = []
async def fake_fetch(path):
requested_paths.append(path)
return []
async def fake_page(stream, page=1, limit=50):
requested_streams.append((stream, page, limit))
return {"stream": stream, "items": [], "page": page, "total": 0, "has_more": False}
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
assert await gitea_proxy.issues() == []
assert await gitea_proxy.pull_requests() == []
assert requested_paths == [
"repos/issues/search?state=open&assigned=true&type=issues&limit=50",
"repos/issues/search?state=open&assigned=true&type=pulls&limit=50",
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
assert requested_streams == [
("issue", 1, 50),
("pull", 1, 50),
("review", 1, 50),
]
@@ -41,12 +290,16 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
"repository": {"full_name": "stackchain/mobile"},
}
async def fake_fetch(path):
if "assigned=true" in path:
return [assigned]
return [assigned.copy(), review_only]
async def fake_page(stream, page=1, limit=50):
items = [assigned] if stream == "pull" else [assigned.copy(), review_only]
reason = "assigned_to_me" if stream == "pull" else "review_requested"
return {
"stream": stream,
"items": [{**item, "work_reasons": [reason]} for item in items],
"page": page, "total": len(items), "has_more": False,
}
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
pulls = await gitea_proxy.pull_requests()
@@ -60,13 +313,13 @@ 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
async def fake_page(stream, page=1, limit=50):
index = 0 if stream == "pull" else 1
started[index].set()
await release.wait()
return []
return {"stream": stream, "items": [], "page": page, "total": 0, "has_more": False}
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", fake_page)
task = asyncio.create_task(gitea_proxy.pull_requests())
try:
await asyncio.wait_for(
@@ -79,32 +332,75 @@ async def test_pull_request_searches_start_concurrently(monkeypatch):
@pytest.mark.anyio
async def test_requested_review_guard_uses_only_dedicated_review_search(monkeypatch):
requested_paths = []
async def test_requested_review_guard_uses_direct_pull_and_current_user(monkeypatch):
requested = []
async def fake_fetch(path):
requested_paths.append(path)
return [
{
"number": 7,
"repository": {"full_name": "stackchain/api"},
},
{
"number": 8,
"repository": {"full_name": "stackchain/api"},
},
]
requested.append(path)
if path == "user":
return {"login": "timmy"}
assert path == "repos/stackchain/api/pulls/77"
return {
"state": "open",
"requested_reviewers": [{"login": "timmy"}],
}
async def reject_search(*args, **kwargs):
raise AssertionError("authorization must not scan review search pages")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", reject_search)
assert await gitea_proxy.is_requested_review("stackchain/api", 7) is True
assert await gitea_proxy.is_requested_review("stackchain/api", 9) is False
assert await gitea_proxy.is_requested_review("stackchain/private", 7) is False
assert requested_paths == [
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
]
assert await gitea_proxy.is_requested_review("stackchain/api", 77) is True
assert sorted(requested) == ["repos/stackchain/api/pulls/77", "user"]
@pytest.mark.anyio
async def test_assigned_issue_guard_uses_direct_target_and_current_user(monkeypatch):
requested = []
async def fake_fetch(path):
requested.append(path)
if path == "user":
return {"login": "timmy"}
assert path == "repos/stackchain/api/issues/77"
return {
"state": "open",
"assignees": [{"login": "timmy"}],
}
async def reject_search(*args, **kwargs):
raise AssertionError("authorization must not scan work search pages")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", reject_search)
assert await gitea_proxy.is_assigned_issue("stackchain/api", 77) is True
assert sorted(requested) == ["repos/stackchain/api/issues/77", "user"]
@pytest.mark.anyio
async def test_assigned_pull_guard_uses_direct_target_and_current_user(monkeypatch):
requested = []
async def fake_fetch(path):
requested.append(path)
if path == "user":
return {"login": "timmy"}
assert path == "repos/stackchain/api/pulls/77"
return {
"state": "open",
"assignees": [{"login": "timmy"}],
}
async def reject_search(*args, **kwargs):
raise AssertionError("authorization must not scan work search pages")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "work_page", reject_search)
assert await gitea_proxy.is_assigned_pull("stackchain/api", 77) is True
assert sorted(requested) == ["repos/stackchain/api/pulls/77", "user"]
@pytest.mark.anyio
+515 -7
View File
@@ -6,6 +6,13 @@ import pytest
from src import gitea_proxy, main
@pytest.fixture(autouse=True)
def clear_issue_creation_operations():
main._issue_creation_operations.clear()
yield
main._issue_creation_operations.clear()
@pytest.mark.anyio
async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirmed_issue(monkeypatch):
calls = []
@@ -16,15 +23,22 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee):
calls.append((repository, title, body, assignee))
async def labels(repository):
assert repository == "stackchain/api"
return [
{"id": 3, "name": "P0", "color": "d73a4a"},
{"id": 8, "name": "frontend", "color": "1d76db"},
]
async def create(repository, title, body, assignee, label_ids):
calls.append((repository, title, body, assignee, label_ids))
return {
"id": 81,
"number": 17,
"title": title,
"state": "open",
"repository": repository,
"labels": [],
"labels": ["P0"],
"assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
@@ -32,19 +46,185 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create, 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/issues",
json={"title": " Capture mobile work ", "body": " Context "},
json={
"title": " Capture mobile work ",
"body": " Context ",
"label_ids": [3],
},
)
assert response.status_code == 201
assert response.headers["cache-control"] == "no-store"
assert response.json()["number"] == 17
assert response.json()["assignees"] == ["timmy"]
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy")]
assert response.json()["labels"] == ["P0"]
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
@pytest.mark.anyio
async def test_create_issue_replays_one_upstream_result_for_concurrent_idempotent_requests(monkeypatch):
calls = 0
started = asyncio.Event()
release = asyncio.Event()
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
nonlocal calls
calls += 1
started.set()
await release.wait()
return {
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-concurrent"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Capture work"}, headers=headers
))
await started.wait()
second = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Capture work"}, headers=headers
))
await asyncio.sleep(0)
release.set()
responses = await asyncio.gather(first, second)
assert [response.status_code for response in responses] == [201, 201]
assert [response.json()["number"] for response in responses] == [17, 17]
assert calls == 1
@pytest.mark.anyio
async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_key(monkeypatch):
calls = []
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
calls.append(title)
return {
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-payload-conflict"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "First title"}, headers=headers
)
conflict = await client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Changed title"}, headers=headers
)
assert created.status_code == 201
assert conflict.status_code == 409
assert conflict.json()["detail"] == "Idempotency key already used"
assert calls == ["First title"]
@pytest.mark.anyio
async def test_create_issue_retry_recovers_result_after_the_first_request_times_out(monkeypatch):
calls = 0
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
nonlocal calls
calls += 1
await asyncio.sleep(0.03)
return {
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-timeout-recovery"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
timed_out = await client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Slow capture"}, headers=headers
)
await asyncio.sleep(0.03)
recovered = await client.post(
"/api/v1/repos/stackchain/api/issues", json={"title": "Slow capture"}, headers=headers
)
assert timed_out.status_code == 503
assert recovered.status_code == 201
assert recovered.json()["number"] == 17
assert calls == 1
@pytest.mark.anyio
async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_limit(monkeypatch):
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def create(repository, title, body, assignee, label_ids):
return {
"id": 81, "number": 17, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-07T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main, "ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES", 2)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
for index in range(3):
response = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={"title": f"Capture {index}"},
headers={"Idempotency-Key": f"capture-177-bounded-{index}"},
)
assert response.status_code == 201
assert len(main._issue_creation_operations) == 2
assert "capture-177-bounded-0" not in main._issue_creation_operations
@pytest.mark.anyio
@@ -58,12 +238,13 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
"updated_at": "2026-08-07T03:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/17",
"assignees": [{"login": "timmy"}],
"labels": [{"id": 3, "name": "P0"}],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.create_issue(
"stackchain/api", "Capture mobile work", "Context", "timmy"
"stackchain/api", "Capture mobile work", "Context", "timmy", [3]
)
finally:
await gitea_proxy.stop_client()
@@ -72,11 +253,63 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
assert requests[0].method == "POST"
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues"
assert requests[0].content == (
b'{"title":"Capture mobile work","body":"Context","assignee":"timmy"}'
b'{"title":"Capture mobile work","body":"Context","assignee":"timmy","labels":[3]}'
)
assert result["repository"] == "stackchain/api"
assert result["number"] == 17
assert result["assignees"] == ["timmy"]
assert result["labels"] == ["P0"]
@pytest.mark.anyio
async def test_gitea_repo_labels_returns_safe_touch_picker_options():
requests = []
async def handler(request):
requests.append(request)
return httpx.Response(200, json=[
{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"},
{"id": "bad", "name": "invalid", "color": "000000"},
])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.repo_labels("stackchain/api")
finally:
await gitea_proxy.stop_client()
assert requests[0].url.path == "/api/v1/repos/stackchain/api/labels"
assert requests[0].url.params["limit"] == "50"
assert result == [{
"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"
}]
@pytest.mark.anyio
async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository(monkeypatch):
calls = []
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def labels(repository):
calls.append(repository)
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
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/labels")
missing = await client.get("/api/v1/repos/other/private/labels")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == [{
"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"
}]
assert missing.status_code == 404
assert calls == ["stackchain/api"]
@pytest.mark.anyio
@@ -100,6 +333,39 @@ async def test_create_issue_rejects_blank_title_before_upstream(monkeypatch):
assert called is False
@pytest.mark.anyio
async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
created = False
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def labels(_repository):
return [{"id": 3, "name": "P0"}]
async def create(*_args):
nonlocal created
created = True
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
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",
json={"title": "Urgent work", "label_ids": [999]},
)
assert response.status_code == 422
assert response.json()["detail"] == "Unknown repository label"
assert created is False
@pytest.mark.anyio
async def test_gitea_create_issue_requires_confirmed_self_assignment():
async def handler(_request):
@@ -118,6 +384,144 @@ async def test_gitea_create_issue_requires_confirmed_self_assignment():
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_assignee():
requests = []
async def handler(request):
requests.append(request)
if request.method == "GET" and request.url.path.endswith("/issues/17"):
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Available", "state": "open",
"assignees": None, "pull_request": None, "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17",
})
if request.method == "GET" and request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Available", "state": "open",
"assignees": [{"login": "timmy"}], "labels": [],
"html_url": "https://forge.example/stackchain/api/issues/17",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.claim_available_issue("stackchain/api", 17)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("GET", "/api/v1/user"),
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
]
assert requests[2].content == b'{"assignee":"timmy"}'
assert result["repository"] == "stackchain/api"
assert result["assignees"] == ["timmy"]
@pytest.mark.anyio
async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkeypatch):
calls = []
async def claim(repository, number):
calls.append((repository, number))
return {
"id": 81, "number": number, "title": "Available", "state": "open",
"repository": repository, "labels": [], "assignees": ["timmy"],
"url": "https://forge.example/stackchain/api/issues/17",
}
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
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/17/claim")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["assignees"] == ["timmy"]
assert calls == [("stackchain/api", 17)]
@pytest.mark.anyio
async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflict(monkeypatch):
async def claim(_repository, _number):
raise gitea_proxy.IssueNotAvailableError("claimed")
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
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/17/claim")
assert response.status_code == 409
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"error": "This issue was already claimed or is no longer open. Refresh Find Work."
}
@pytest.mark.anyio
async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_peers():
requests = []
async def handler(request):
requests.append(request)
if request.method == "GET" and request.url.path.endswith("/issues/17"):
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Shared work", "state": "open",
"pull_request": None,
"assignees": [{"login": "timmy"}, {"login": "alex"}],
"labels": [],
})
if request.method == "GET" and request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 81, "number": 17, "title": "Shared work", "state": "open",
"assignees": [{"login": "alex"}], "labels": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.release_assigned_issue("stackchain/api", 17)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
]
assert requests[2].content == b'{"assignees":["alex"]}'
assert result["assignees"] == ["alex"]
assert result["available"] is False
@pytest.mark.anyio
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
calls = []
async def release(repository, number):
calls.append((repository, number))
return {
"number": number, "repository": repository, "state": "open",
"assignees": [], "available": True,
}
monkeypatch.setattr(main.gitea_proxy, "release_assigned_issue", release, raising=False)
monkeypatch.setattr(main, "_available_issue_snapshot_value", [{"number": 99}])
monkeypatch.setattr(main, "_available_issue_snapshot_created_at", 123.0)
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/17/release")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["available"] is True
assert calls == [("stackchain/api", 17)]
assert main._available_issue_snapshot_value is None
assert main._available_issue_snapshot_created_at is None
@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
async def assigned(repository, number):
@@ -380,3 +784,107 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
}
],
}
@pytest.mark.anyio
async def test_update_assigned_issue_labels_validates_and_returns_confirmed_labels(monkeypatch):
calls = []
async def assigned(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def labels(repository):
assert repository == "stackchain/api"
return [
{"id": 3, "name": "P0", "color": "d73a4a"},
{"id": 8, "name": "frontend", "color": "1d76db"},
]
async def update(repository, number, label_ids):
calls.append((repository, number, label_ids))
return {"number": number, "labels": ["P0", "frontend"]}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
monkeypatch.setattr(main.gitea_proxy, "update_issue_labels", update, raising=False)
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/labels",
json={"label_ids": [3, 8]},
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"number": 7, "labels": ["P0", "frontend"]}
assert calls == [("stackchain/api", 7, [3, 8])]
@pytest.mark.anyio
async def test_assigned_issue_label_options_do_not_depend_on_repository_listing(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def labels(repository):
calls.append(("labels", repository))
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
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/labels")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()[0]["name"] == "P0"
assert calls == [
("assigned", "stackchain/api", 7),
("labels", "stackchain/api"),
]
@pytest.mark.anyio
async def test_gitea_update_issue_labels_patches_ids_and_normalizes_confirmation():
requests = []
async def handler(request):
requests.append(request)
return httpx.Response(200, json={
"number": 7,
"labels": [
{"id": 3, "name": "P0"},
{"id": 8, "name": "frontend"},
],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.update_issue_labels("stackchain/api", 7, [3, 8])
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'{"labels":[3,8]}'
assert result == {"number": 7, "labels": ["P0", "frontend"]}
@pytest.mark.anyio
async def test_gitea_update_issue_labels_rejects_unconfirmed_label_set():
async def handler(_request):
return httpx.Response(200, json={
"number": 7,
"labels": [{"id": 8, "name": "frontend"}],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(ValueError, match="confirm"):
await gitea_proxy.update_issue_labels("stackchain/api", 7, [3])
finally:
await gitea_proxy.stop_client()
+90 -10
View File
@@ -11,14 +11,30 @@ 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
main._live_section_created_at = {
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_section_failure_count = {
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_section_retry_at = {
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_snapshot_refreshing_sections = set()
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
main._live_section_created_at = {
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_section_failure_count = {
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_section_retry_at = {
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_snapshot_refreshing_sections = set()
def payload(response):
@@ -209,7 +225,7 @@ async def test_stale_snapshot_returns_immediately_while_one_refresh_revalidates(
release_refresh = asyncio.Event()
builds = 0
async def snapshot():
async def snapshot(_sections=None):
nonlocal builds
builds += 1
if builds == 2:
@@ -247,7 +263,7 @@ async def test_failed_revalidation_enters_cooldown_and_keeps_last_snapshot(monke
now = 100.0
builds = 0
async def snapshot():
async def snapshot(_sections=None):
nonlocal builds
builds += 1
if builds > 1:
@@ -288,7 +304,7 @@ async def test_partial_refresh_updates_fresh_sections_and_retains_failed_section
now = 100.0
builds = 0
async def snapshot():
async def snapshot(_sections=None):
nonlocal builds
builds += 1
if builds == 1:
@@ -332,6 +348,70 @@ async def test_partial_refresh_updates_fresh_sections_and_retains_failed_section
assert result["freshness"]["revalidating"] is False
@pytest.mark.anyio
async def test_notification_cooldown_does_not_stop_due_work_and_activity_refreshes(monkeypatch):
now = 100.0
calls = {"context": 0, "events": 0, "notifications": 0}
event_refresh_times = []
notification_outage = False
async def user():
return {"id": 1, "login": "timmy"}
async def work():
calls["context"] += 1
return []
async def empty_work():
return []
async def events(_authenticated_user):
calls["events"] += 1
event_refresh_times.append(now)
return [{"generation": calls["events"]}]
async def updates():
calls["notifications"] += 1
if notification_outage:
raise ConnectionError("notifications unavailable")
return []
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", work)
monkeypatch.setattr(main, "issues", empty_work)
monkeypatch.setattr(main, "pull_requests", empty_work)
monkeypatch.setattr(main, "activity_events", events)
monkeypatch.setattr(main, "notifications", updates)
await main.live_snapshot()
notification_outage = True
for elapsed in (
main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1,
main.LIVE_SNAPSHOT_RETRY_BASE_SECONDS,
):
now += elapsed
await main.live_snapshot()
assert main._live_snapshot_task is not None
await main._live_snapshot_task
# Notification backoff is now 10 seconds. The healthy sections become due
# one second before that cooldown expires and must refresh independently.
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
response = payload(await main.live_snapshot())
assert main._live_snapshot_task is not None
await main._live_snapshot_task
refreshed = payload(await main.live_snapshot())
assert calls == {"context": 3, "events": 3, "notifications": 3}
assert event_refresh_times == [100.0, 109.0, 123.0]
assert response["freshness"]["sections"]["context"]["revalidating"] is True
assert refreshed["events"] == [{"generation": 3}]
assert refreshed["sections"]["notifications"] == "stale"
assert refreshed["freshness"]["sections"]["notifications"]["retry_in_seconds"] == 1
@pytest.mark.anyio
async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state(monkeypatch):
now = 100.0
@@ -339,7 +419,7 @@ async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state
retry_started = asyncio.Event()
release_retry = asyncio.Event()
async def snapshot():
async def snapshot(_sections=None):
nonlocal builds
builds += 1
if builds == 1:
@@ -399,7 +479,7 @@ async def test_cancelling_one_waiter_does_not_cancel_the_shared_snapshot(monkeyp
release = asyncio.Event()
snapshot_calls = 0
async def blocked_snapshot():
async def blocked_snapshot(_sections=None):
nonlocal snapshot_calls
snapshot_calls += 1
started.set()
@@ -437,7 +517,7 @@ async def test_shared_snapshot_deadline_cancels_upstream_work_for_all_waiters(mo
started = asyncio.Event()
cancelled = asyncio.Event()
async def snapshot_that_exceeds_deadline():
async def snapshot_that_exceeds_deadline(_sections=None):
started.set()
try:
await asyncio.Event().wait()
+686 -5
View File
@@ -11,6 +11,8 @@ MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
@@ -76,6 +78,83 @@ process.stdout.write(JSON.stringify(queue));
assert queue[2]["reason"] == "Assigned to you"
def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue():
payload = {
"user": {"login": "timmy"},
"issues": [
{"number": 1, "title": "Older", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z"},
{"number": 2, "title": "Newer", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T12:00:00Z"},
],
"pull_requests": [],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = {json.dumps(payload)};
const updated = buildMyWork.replaceIssueLabels(original, 'stackchain/api', 1, ['P0']);
process.stdout.write(JSON.stringify({{
titles: buildMyWork(updated).map(item => item.title),
labels: updated.issues[0].labels,
original: original.issues[0].labels,
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"titles": ["Older", "Newer"],
"labels": ["P0"],
"original": [],
}
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let calls = 0;
let finish;
const controller = createIssueSheet({{
fetchJson: () => {{
calls += 1;
return new Promise(resolve => {{ finish = resolve; }});
}},
storage: null,
}});
const item = {{repository:'stackchain/api', number:17}};
const first = controller.release(item, 'timmy');
const duplicate = controller.release(item, 'timmy');
finish({{number:17, repository:'stackchain/api', assignees:[], available:true}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, same: first === duplicate, results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert output["same"] is True
assert output["results"][0]["available"] is True
def test_issue_release_rejects_response_that_still_assigns_current_user():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const controller = createIssueSheet({{
fetchJson: async () => ({{number:17, assignees:['timmy']}}), storage: null,
}});
controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
.then(() => process.stdout.write('unexpected'))
.catch(error => process.stdout.write(error.message));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert result.stdout == "Issue release was not confirmed."
def test_my_work_reviews_filter_and_summary_are_actionable():
items = [
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
@@ -118,6 +197,254 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1, "update": 0}
def test_work_pager_is_single_flight_and_unions_pull_responsibilities():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let calls = 0;
let release;
const pages = [];
const states = [];
const statuses = [];
const pager = buildMyWork.createWorkPager({{
load: (stream, page) => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{
stream, page, total: 51, has_more: false,
items: [
{{id:1, title:'shared', work_reasons:['review_requested']}},
{{id:51, title:'older', work_reasons:['review_requested']}}
],
}}); }});
}},
onItems: (stream, items) => states.push({{stream, items}}),
onPagination: pagination => pages.push(pagination),
onStatus: status => statuses.push(status),
}});
pager.reset({{review:{{page:1,total:51,has_more:true}}}});
const existing = [{{id:1,title:'shared',work_reasons:['assigned_to_me']}}];
const first = pager.loadMore('review', existing);
const duplicate = pager.loadMore('review', existing);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, pages, states, statuses, results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert output["states"] == [{
"stream": "review",
"items": [
{"id": 1, "title": "shared", "work_reasons": ["assigned_to_me", "review_requested"]},
{"id": 51, "title": "older", "work_reasons": ["review_requested"]},
],
}]
assert output["pages"][-1]["review"] == {"page": 2, "total": 51, "has_more": False}
assert output["statuses"] == [
"Loading older review requests…", "51 of 51 review requests loaded."
]
assert output["results"] == [True, False]
def test_work_pager_keeps_items_and_retries_same_page_after_failure():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const requested = [];
const states = [];
const statuses = [];
const pager = buildMyWork.createWorkPager({{
load: async (stream, page) => {{ requested.push([stream,page]); throw new Error('offline'); }},
onItems: (stream, items) => states.push(items),
onPagination: () => {{}},
onStatus: status => statuses.push(status),
}});
pager.reset({{issue:{{page:2,total:125,has_more:true}}}});
pager.loadMore('issue', [{{id:1}}]).then(result =>
pager.loadMore('issue', [{{id:1}}]).then(retry =>
process.stdout.write(JSON.stringify({{requested,states,statuses,result,retry}}))
)
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"requested": [["issue", 3], ["issue", 3]],
"states": [],
"statuses": [
"Loading older issues…", "Could not load older issues. Retry.",
"Loading older issues…", "Could not load older issues. Retry.",
],
"result": False,
"retry": False,
}
def test_find_work_claim_is_single_flight_and_removes_only_confirmed_issue():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
let calls = 0;
let release;
const states = [];
const statuses = [];
const pages = [];
const controller = createFindWork({{
fetchJson: (url, options) => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{
id:17,number:7,title:'Available',repository:'stackchain/api',
state:'open',labels:[],assignees:['timmy'],url:'https://forge.example/issues/7'
}}); }});
}},
onItems: items => states.push(items),
onPagination: page => pages.push(page),
onStatus: status => statuses.push(status),
}});
controller.reset({{items:[
{{id:17,number:7,title:'Available',repository:'stackchain/api'}},
{{id:18,number:8,title:'Other',repository:'stackchain/web'}}
],page:1,total:2,has_more:false}});
const item = controller.items()[0];
const first = controller.claim(item);
const duplicate = controller.claim(item);
release();
Promise.all([first,duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls,states,statuses,pages,results,remaining:controller.items()
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert [item["number"] for item in output["remaining"]] == [8]
assert [item["number"] for item in output["states"][-1]] == [8]
assert output["statuses"] == ["Assigning stackchain/api#7…", "Assigned stackchain/api#7 to you."]
assert output["pages"][-1] == {"page": 1, "total": 1, "has_more": False}
assert output["results"][0]["assignees"] == ["timmy"]
assert output["results"][1]["assignees"] == ["timmy"]
def test_find_work_loads_paginated_results_without_duplicates():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
const calls = [];
const states = [];
const pages = [];
const controller = createFindWork({{
fetchJson: url => {{
calls.push(url);
const page = Number(new URL(url, 'https://example.test/').searchParams.get('page'));
return Promise.resolve(page === 1 ? {{
items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:2,has_more:true
}} : {{
items:[{{id:1,number:1,repository:'stackchain/api'}},{{id:2,number:2,repository:'stackchain/web'}}],
page:2,total:2,has_more:false
}});
}},
onItems: items => states.push(items),
onPagination: page => pages.push(page),
onStatus: () => {{}},
}});
controller.load().then(() => controller.loadMore()).then(() =>
process.stdout.write(JSON.stringify({{calls,states,pages,items:controller.items()}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == ["api/v1/available-issues?page=1", "api/v1/available-issues?page=2"]
assert [item["id"] for item in output["items"]] == [1, 2]
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
def test_find_work_preview_stays_with_issue_across_pagination_and_clears_when_claimed():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
const states = [];
const controller = createFindWork({{
fetchJson: (url, options) => options?.method === 'PATCH'
? Promise.resolve({{number:7,repository:'stackchain/api',assignees:['timmy']}})
: Promise.resolve({{
items:[
{{id:17,number:7,title:'Preview me',repository:'stackchain/api',body:'Full scope'}},
{{id:18,number:8,title:'Next',repository:'stackchain/web',body:''}}
],page:2,total:2,has_more:false
}}),
onItems: items => states.push(items),
onPagination: () => {{}},
onStatus: () => {{}},
}});
controller.reset({{
items:[{{id:17,number:7,title:'Preview me',repository:'stackchain/api',body:'Full scope'}}],
page:1,total:2,has_more:true
}});
const target = controller.items()[0];
const opened = controller.togglePreview(target);
controller.loadMore().then(() => {{
const afterPagination = controller.isPreviewed(controller.items()[0]);
return controller.claim(controller.items()[0]).then(() => process.stdout.write(JSON.stringify({{
opened, afterPagination, afterClaim:controller.isPreviewed(target), states
}})));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["opened"] is True
assert output["afterPagination"] is True
assert output["afterClaim"] is False
assert [item["number"] for item in output["states"][-1]] == [8]
@pytest.mark.anyio
async def test_mobile_my_work_exposes_truthful_work_pagination_control():
html = await dashboard()
assert 'id="work-page-status"' in html
assert 'id="load-more-work"' in html
assert '>Load older work<' in html
assert '.load-more-work { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe():
html = await dashboard()
assert 'id="find-work"' in html
assert 'id="find-work-sheet" role="dialog" aria-modal="true"' in html
assert 'id="find-work-list"' in html
assert 'id="find-work-status" class="small" aria-live="assertive"' in html
assert 'id="load-more-available"' in html
assert 'static/pick-work.js' in html
assert '.find-work-action { min-height:44px;' in html
assert 'padding-bottom:calc(18px + env(safe-area-inset-bottom))' in html
assert '@media(max-width:320px)' in html
@pytest.mark.anyio
async def test_mobile_find_work_cards_preview_escaped_context_without_extra_requests():
html = await dashboard()
assert 'data-preview-index=' in html
assert 'aria-expanded="' in html
assert 'aria-controls="' in html
assert "const detailId = 'find-work-detail-' + index" in html
assert 'class="find-work-detail"' in html
assert "escapeHtml(item.body || 'No description provided.')" in html
assert '>Open in Gitea</a>' in html
assert '.find-work-card a, .find-work-more { min-height:44px;' in html
assert '.find-work-detail { min-width:0;' in html
def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
@@ -139,7 +466,7 @@ const capture = createIssueCapture({{
}}); }});
}},
}});
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context'}};
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context', labelIds:[3]}};
capture.saveDraft(draft);
const first = capture.submit(draft);
const duplicate = capture.submit(draft);
@@ -158,16 +485,92 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/api/issues",
"method": "POST",
"body": {"title": "Capture work", "body": "Context"},
"body": {"title": "Capture work", "body": "Context", "label_ids": [3]},
}]
assert output["during"] == {
"repository": "stackchain/api", "title": "Capture work", "body": "Context"
"repository": "stackchain/api", "title": "Capture work", "body": "Context",
"labelIds": [3],
}
assert output["after"] == {
"repository": "", "title": "", "body": "", "labelIds": []
}
assert output["after"] == {"repository": "", "title": "", "body": ""}
assert output["results"][0]["number"] == 17
assert output["results"][1]["number"] == 17
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const calls = [];
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context', labelIds:[3]}};
const first = createIssueCapture({{
storage,
createOperationId: () => 'operation-177',
fetchJson: (_url, options) => {{ calls.push(options.headers['Idempotency-Key']); return Promise.reject(new Error('timeout')); }},
}});
first.submit(draft).catch(() => {{
const restored = createIssueCapture({{
storage,
createOperationId: () => 'must-not-replace-operation-177',
fetchJson: (_url, options) => {{
calls.push(options.headers['Idempotency-Key']);
return Promise.resolve({{number:17, title:'Capture work'}});
}},
}});
const before = restored.loadDraft();
restored.submit(before).then(issue => process.stdout.write(JSON.stringify({{
calls, before, after:restored.loadDraft(), number:issue.number
}})));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == ["operation-177", "operation-177"]
assert output["before"] == {
"repository": "stackchain/api", "title": "Capture work", "body": "Context",
"labelIds": [3],
}
assert output["after"] == {
"repository": "", "title": "", "body": "", "labelIds": []
}
assert output["number"] == 17
def test_issue_capture_loads_repository_labels_with_priorities_first():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const calls = [];
const capture = createIssueCapture({{
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
fetchJson: url => {{
calls.push(url);
return Promise.resolve([
{{id:8,name:'frontend',color:'1d76db'}},
{{id:3,name:'P0',color:'d73a4a'}},
{{id:5,name:'critical',color:'b60205'}}
]);
}},
}});
capture.loadLabels('stackchain/api').then(labels => process.stdout.write(JSON.stringify({{calls,labels}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == ["api/v1/repos/stackchain/api/labels"]
assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"]
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
payload = {
"user": {"login": "timmy"},
@@ -620,6 +1023,30 @@ controller.load({{repository:'stackchain/api', number:7}}).then(detail =>
}
def test_issue_sheet_loads_labels_through_assigned_issue_boundary():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let request;
const controller = createIssueSheet({{ fetchJson: async (url, options) => {{
request = {{url, accept:options.headers.Accept}};
return [{{id:3, name:'P0'}}];
}} }});
controller.loadLabels({{repository:'stackchain/api', number:7}}).then(labels =>
process.stdout.write(JSON.stringify({{request, labels}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"request": {
"url": "api/v1/repos/stackchain/api/issues/7/labels",
"accept": "application/json",
},
"labels": [{"id": 3, "name": "P0"}],
}
def test_issue_sheet_comment_is_single_flight_and_preserves_draft_until_success():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -659,6 +1086,51 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
assert output["results"] == [{"id": 82, "body": "Ready"}, {"id": 82, "body": "Ready"}]
def test_issue_sheet_label_save_is_single_flight_and_keeps_selection_until_confirmed():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const calls = [];
let release;
const controller = createIssueSheet({{
storage,
fetchJson: (url, options={{}}) => {{
calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
return new Promise(resolve => {{ release = () => resolve({{number:7, labels:['P0']}}); }});
}},
}});
const item = {{repository:'stackchain/api', number:7}};
const first = controller.updateLabels(item, [3]);
const duplicate = controller.updateLabels(item, [3]);
const during = controller.loadLabelDraft(item);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, during, after:controller.loadLabelDraft(item), results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/api/issues/7/labels",
"method": "PATCH",
"body": {"label_ids": [3]},
}]
assert output["during"] == [3]
assert output["after"] == []
assert output["results"] == [
{"number": 7, "labels": ["P0"]},
{"number": 7, "labels": ["P0"]},
]
def test_issue_sheet_close_is_single_flight_and_waits_for_confirmed_closed_state():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -691,6 +1163,80 @@ Promise.all([first, duplicate]).then(results =>
}
def test_pull_sheet_preserves_comment_draft_and_single_flights_mutations():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const calls = [];
let releaseComment;
const controller = createPullSheet({{
storage,
fetchJson: (url, options={{}}) => {{
calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
if (url.endsWith('/comments')) return new Promise(resolve => {{ releaseComment = () => resolve({{id:91, body:'Ship it'}}); }});
return Promise.resolve({{number:7, merged:true, state:'closed'}});
}},
}});
const item = {{repository:'stackchain/api', number:7}};
controller.saveDraft(item, 'Ship it');
const first = controller.comment(item, 'Ship it');
const duplicate = controller.comment(item, 'Ship it');
const during = controller.loadDraft(item);
releaseComment();
Promise.all([first, duplicate]).then(async comments => {{
const merges = await Promise.all([controller.merge(item, 'abc123'), controller.merge(item, 'abc123')]);
process.stdout.write(JSON.stringify({{calls, during, after:controller.loadDraft(item), comments, merges}}));
}});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert output["during"] == "Ship it"
assert output["after"] == ""
assert output["calls"] == [
{"url": "api/v1/repos/stackchain/api/pulls/7/comments", "method": "POST", "body": {"body": "Ship it"}},
{"url": "api/v1/repos/stackchain/api/pulls/7/merge", "method": "POST", "body": {"expected_head_sha": "abc123"}},
]
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
def test_pull_sheet_enables_merge_only_for_current_safe_state():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const states = [
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
{{state:'open', draft:true, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'failure', head_sha:'abc'}},
{{state:'open', draft:false, mergeable:false, merged:false, ci_state:'success', head_sha:'abc'}},
];
process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert output[0] == {"allowed": True, "reason": "Ready to merge"}
assert output[1]["allowed"] is False and "draft" in output[1]["reason"].lower()
assert output[2]["allowed"] is False and "CI" in output[2]["reason"]
assert output[3]["allowed"] is False and "conflict" in output[3]["reason"].lower()
@pytest.mark.anyio
async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
html = await dashboard()
assert 'id="pull-sheet"' in html and 'aria-modal="true"' in html
assert 'class="my-work-card-main pull-trigger"' in html
assert 'id="pull-sheet-status"' in html
assert 'id="pull-files"' in html and 'id="pull-comments"' in html
assert 'id="pull-comment"' in html and 'maxlength="10000"' in html
assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html
assert '<script src="static/pull-sheet.js"></script>' in html
assert "pullController.load(item)" in html
assert "window.confirm('Merge ' + selectedPull.key" in html
assert "expected_head_sha" in html
assert "item.kind === 'pull'" in html and "pull-trigger" in html
@pytest.mark.anyio
async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mutations():
html = await dashboard()
@@ -720,6 +1266,55 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut
assert "e.key === 'Escape' && selectedIssue" in html
@pytest.mark.anyio
async def test_mobile_issue_sheet_releases_assignment_with_confirmed_local_removal():
html = await dashboard()
assert 'id="release-issue"' in html
assert 'Release assignment' in html
assert "window.confirm('Release ' + selectedIssue.key + ' from your My Work?')" in html
assert "issueController.release(selectedIssue, lastContextSnapshot?.user?.login)" in html
assert "buildMyWork.removeIssue(" in html
def test_remove_issue_updates_snapshot_without_mutating_other_work():
payload = {
"issues": [
{"repository": "stackchain/api", "number": 17},
{"repository": "stackchain/web", "number": 18},
],
"pull_requests": [{"repository": "stackchain/api", "number": 17}],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = {json.dumps(payload)};
const updated = buildMyWork.removeIssue(original, 'stackchain/api', 17);
process.stdout.write(JSON.stringify({{updated, original}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["updated"]["issues"] == [{"repository": "stackchain/web", "number": 18}]
assert output["updated"]["pull_requests"] == payload["pull_requests"]
assert output["original"]["issues"] == payload["issues"]
@pytest.mark.anyio
async def test_mobile_issue_sheet_edits_labels_and_repaints_confirmed_priority():
html = await dashboard()
assert 'id="issue-label-editor"' in html
assert 'aria-describedby="issue-label-status"' in html
assert 'id="issue-label-list"' in html
assert 'id="save-issue-labels"' in html
assert '.issue-label-option { min-height:44px;' in html
assert 'max-width:100%;' in html
assert "issueController.updateLabels(selectedIssue" in html
assert "buildMyWork.replaceIssueLabels" in html
assert "paintMyWork(lastContextSnapshot)" in html
@pytest.mark.anyio
async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet():
html = await dashboard()
@@ -729,6 +1324,8 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
assert 'id="create-issue-repository"' in html
assert 'id="create-issue-title"' in html and 'maxlength="255"' in html
assert 'id="create-issue-body"' in html and 'maxlength="10000"' in html
assert 'id="create-issue-labels"' in html and 'aria-describedby="create-issue-label-status"' in html
assert 'id="create-issue-label-status"' in html and 'aria-live="polite"' in html
assert 'id="submit-new-issue"' in html
assert '.create-issue-sheet.open { display:flex; }' in html
assert 'height:100dvh;' in html
@@ -737,6 +1334,9 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
assert 'openIssueSheet(created' in html
assert 'issueCapture.loadLabels(repository)' in html
assert "input[name=\"create-issue-label\"]:checked" in html
assert '.create-issue-label-option' in html and 'min-height:44px' in html
@pytest.mark.anyio
@@ -964,11 +1564,43 @@ process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expand
assert 'Preview truncated' in output["html"]
assert 'class="review-mark"' in output["html"]
assert 'data-review-filename="src/&lt;api&gt;.py"' in output["html"]
assert 'class="review-diff-line review-inline-target removed"' in output["html"]
assert 'data-old-position="1"' in output["html"]
assert 'data-new-position="1"' in output["html"]
assert 'aria-label="Comment on src/&lt;api&gt;.py line 1"' in output["html"]
assert 'Mark reviewed' in output["html"]
assert output["expanded"] == "true"
assert output["hidden"] is False
def test_review_diff_parser_maps_multi_hunk_lines_to_old_or_new_positions():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const rows = reviewSheet.parseDiffLines([
'@@ -10,3 +20,4 @@ function run()',
' context', '-removed', '+added', '+second',
String.raw`\\ No newline at end of file`,
'@@ -40 +51 @@', '-old tail', '+new tail'
]);
process.stdout.write(JSON.stringify(rows));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == [
{"text": "@@ -10,3 +20,4 @@ function run()", "kind": "hunk", "commentable": False},
{"text": " context", "kind": "context", "commentable": True, "new_position": 20},
{"text": "-removed", "kind": "removed", "commentable": True, "old_position": 11},
{"text": "+added", "kind": "added", "commentable": True, "new_position": 21},
{"text": "+second", "kind": "added", "commentable": True, "new_position": 22},
{"text": "\\ No newline at end of file", "kind": "note", "commentable": False},
{"text": "@@ -40 +51 @@", "kind": "hunk", "commentable": False},
{"text": "-old tail", "kind": "removed", "commentable": True, "old_position": 40},
{"text": "+new tail", "kind": "added", "commentable": True, "new_position": 51},
]
def test_review_progress_is_explicit_and_restores_for_the_same_head_sha():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
@@ -1050,11 +1682,42 @@ process.stdout.write(JSON.stringify(restored));
assert json.loads(result.stdout) == {
"notes": {"src/a.py": "Handle the empty state."},
"comments": [],
"summary": "One blocker remains.",
"decision": "request_changes",
}
def test_review_draft_persists_edits_and_removes_inline_comments_for_same_head():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const options = {{storage, repository:'stackchain/api', number:7, headSha:'abc123', files:[{{filename:'src/a.py'}}]}};
const first = reviewSheet.createDraft(options);
first.setInlineComment({{path:'src/a.py', new_position:42}}, 'Handle empty values.');
first.setInlineComment({{path:'src/a.py', new_position:42}}, 'Handle null and empty values.');
first.setInlineComment({{path:'src/a.py', old_position:9}}, 'Why remove this guard?');
const restored = reviewSheet.createDraft(options);
const beforeRemove = restored.snapshot().comments;
const afterRemove = restored.removeInlineComment({{path:'src/a.py', old_position:9}}).comments;
process.stdout.write(JSON.stringify({{beforeRemove, afterRemove}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"beforeRemove": [
{"path": "src/a.py", "body": "Handle null and empty values.", "new_position": 42},
{"path": "src/a.py", "body": "Why remove this guard?", "old_position": 9},
],
"afterRemove": [
{"path": "src/a.py", "body": "Handle null and empty values.", "new_position": 42},
],
}
def test_review_feedback_formats_non_empty_file_notes_in_changed_file_order():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
@@ -1204,6 +1867,22 @@ async def test_mobile_review_sheet_captures_and_safely_hands_off_feedback():
assert "handoffWindow.opener = null" in html
@pytest.mark.anyio
async def test_mobile_review_sheet_edits_inline_drafts_and_submits_them_with_review():
html = await dashboard()
assert 'id="review-inline-composer"' in html
assert 'id="review-inline-body"' in html and 'maxlength="10000"' in html
assert 'id="save-inline-comment"' in html
assert 'id="delete-inline-comment"' in html
assert '.review-inline-target' in html and 'min-height:44px' in html
assert '.review-inline-composer' in html and 'position:sticky' in html
assert "document.querySelectorAll('.review-inline-target')" in html
assert "draft.setInlineComment" in html
assert "draft.removeInlineComment" in html
assert "comments: snapshot.comments" in html
def test_review_controller_submits_once_while_request_is_in_flight():
script = f"""
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
@@ -1280,7 +1959,9 @@ process.stdout.write(JSON.stringify({{removed, draft:draft.snapshot(), progress:
"stackchain.review-draft.v1:stackchain/api#7@abc123",
"stackchain.review-progress.v1:stackchain/api#7@abc123",
]
assert payload["draft"] == {"notes": {}, "summary": "", "decision": "comment"}
assert payload["draft"] == {
"notes": {}, "comments": [], "summary": "", "decision": "comment"
}
assert payload["progress"]["reviewedCount"] == 0
+2 -2
View File
@@ -214,7 +214,7 @@ async def test_inflight_refresh_cannot_restore_a_notification_marked_read(monkey
async def mark(_thread_id):
return None
async def stale_upstream_snapshot():
async def stale_upstream_snapshot(_sections):
refresh_started.set()
await release_refresh.wait()
return {
@@ -231,7 +231,7 @@ async def test_inflight_refresh_cannot_restore_a_notification_marked_read(monkey
monkeypatch.setattr(main, "_live_snapshot_task", None)
monkeypatch.setattr(main, "_live_snapshot_value", {"notifications": [{"id": 42}]})
refresh = main._start_live_snapshot_refresh()
refresh = main._start_live_snapshot_refresh(set(main.LIVE_SNAPSHOT_SECTIONS))
await refresh_started.wait()
await main.read_notification(42)
release_refresh.set()
+149
View File
@@ -0,0 +1,149 @@
import httpx
import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
async def assigned(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def detail(repository, number):
assert (repository, number) == ("stackchain/api", 7)
return {
"repository": repository,
"number": number,
"title": "Ship mobile flow",
"body": "Ready to merge",
"url": "https://forge.example/stackchain/api/pulls/7",
"author": "alex",
"head_sha": "abc123",
"state": "open",
"draft": False,
"mergeable": True,
"merged": False,
"ci_state": "success",
"files": [{"filename": "src/api.py", "additions": 8, "deletions": 2}],
"comments": [{"id": 9, "author": "sam", "body": "Ship it"}],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned, raising=False)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_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/repos/stackchain/api/pulls/7/detail")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["head_sha"] == "abc123"
assert response.json()["mergeable"] is True
@pytest.mark.anyio
async def test_assigned_pull_detail_rejects_unassigned_pull(monkeypatch):
async def assigned(repository, number):
return False
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/repos/private/secret/pulls/9/detail")
assert response.status_code == 404
@pytest.mark.anyio
async def test_assigned_pull_comment_posts_only_after_assignment_check(monkeypatch):
calls = []
async def assigned(repository, number):
return True
async def comment(repository, number, body):
calls.append((repository, number, body))
return {"id": 91, "author": "timmy", "body": body}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", 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/pulls/7/comments",
json={"body": " Ready to ship. "},
)
assert response.status_code == 201
assert calls == [("stackchain/api", 7, "Ready to ship.")]
@pytest.mark.anyio
async def test_assigned_pull_merge_requires_current_eligible_head(monkeypatch):
calls = []
async def assigned(repository, number):
return True
async def merge(repository, number, expected_head_sha):
calls.append((repository, number, expected_head_sha))
return {"number": number, "merged": True, "state": "closed"}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge, 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/merge",
json={"expected_head_sha": "abc123"},
)
assert response.status_code == 200
assert response.json() == {"number": 7, "merged": True, "state": "closed"}
assert calls == [("stackchain/api", 7, "abc123")]
@pytest.mark.anyio
async def test_assigned_pull_merge_returns_conflict_without_mutating_stale_head(monkeypatch):
async def assigned(repository, number):
return True
async def merge(*args):
raise gitea_proxy.StalePullError("changed")
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge, 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/merge",
json={"expected_head_sha": "abc123"},
)
assert response.status_code == 409
assert "New commits" in response.json()["error"]
@pytest.mark.anyio
async def test_gitea_merge_rejects_failed_ci_without_upstream_mutation():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7, "state": "open", "draft": False, "mergeable": True,
"merged": False, "head": {"sha": "abc123"},
})
return httpx.Response(200, json={"state": "failure"})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(gitea_proxy.PullNotMergeableError):
await gitea_proxy.merge_assigned_pull("stackchain/api", 7, "abc123")
finally:
await gitea_proxy.stop_client()
assert requests == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/commits/abc123/status"),
]
+119
View File
@@ -111,6 +111,41 @@ async def test_review_submission_posts_validated_decision_for_requested_current_
]
@pytest.mark.anyio
async def test_review_submission_forwards_valid_inline_comments(monkeypatch):
calls = []
async def requested(repository, number):
return True
async def submit(repository, number, expected_head_sha, decision, body, comments):
calls.append((repository, number, expected_head_sha, decision, body, comments))
return {"id": 92, "state": "REQUEST_CHANGES", "url": "https://forge.example/reviews/92"}
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": "One inline blocker.",
"expected_head_sha": "abc123",
"comments": [{
"path": "src/api.py", "body": "Handle the empty value.",
"new_position": 42,
}],
},
)
assert response.status_code == 201
assert calls == [(
"stackchain/api", 7, "abc123", "request_changes", "One inline blocker.",
[{"path": "src/api.py", "body": "Handle the empty value.", "new_position": 42, "old_position": None}],
)]
@pytest.mark.anyio
async def test_gitea_review_submission_checks_head_then_maps_decision_upstream():
requests = []
@@ -148,6 +183,38 @@ async def test_gitea_review_submission_checks_head_then_maps_decision_upstream()
}
@pytest.mark.anyio
async def test_gitea_review_submission_sends_inline_comments_in_single_review_request():
requests = []
async def handler(request):
requests.append(request)
if request.url.path.endswith("/files"):
return httpx.Response(200, json=[{"filename": "src/api.py"}])
if request.method == "GET":
return httpx.Response(200, json={"head": {"sha": "abc123"}})
return httpx.Response(200, json={"id": 93, "state": "COMMENT"})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
await gitea_proxy.submit_pull_review(
"stackchain/api", 7, "abc123", "comment", "Summary",
[{"path": "src/api.py", "body": "Handle empty values.", "new_position": 42, "old_position": None}],
)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/pulls/7/files"),
("POST", "/api/v1/repos/stackchain/api/pulls/7/reviews"),
]
assert requests[-1].content == (
b'{"body":"Summary","event":"COMMENT","commit_id":"abc123","comments":'
b'[{"path":"src/api.py","body":"Handle empty values.","new_position":42,"old_position":null}]}'
)
@pytest.mark.anyio
async def test_gitea_review_submission_rejects_changed_head_without_posting():
methods = []
@@ -168,6 +235,32 @@ async def test_gitea_review_submission_rejects_changed_head_without_posting():
assert methods == ["GET"]
@pytest.mark.anyio
async def test_gitea_review_submission_rejects_inline_comment_for_unchanged_path_without_posting():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/files"):
return httpx.Response(200, json=[{"filename": "src/api.py"}])
return httpx.Response(200, json={"head": {"sha": "abc123"}})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(gitea_proxy.InvalidReviewCommentError):
await gitea_proxy.submit_pull_review(
"stackchain/api", 7, "abc123", "comment", "Review note.",
[{"path": "src/unknown.py", "body": "Not in this change", "new_position": 4}],
)
finally:
await gitea_proxy.stop_client()
assert requests == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/pulls/7/files"),
]
@pytest.mark.anyio
async def test_review_submission_returns_conflict_when_pull_head_changed(monkeypatch):
async def requested(repository, number):
@@ -195,6 +288,32 @@ async def test_review_submission_returns_conflict_when_pull_head_changed(monkeyp
}
@pytest.mark.anyio
async def test_review_submission_returns_validation_error_when_inline_path_is_stale(monkeypatch):
async def requested(repository, number):
return True
async def submit(*args):
raise gitea_proxy.InvalidReviewCommentError("unknown path")
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": "comment", "body": "Note", "expected_head_sha": "abc123",
"comments": [{"path": "gone.py", "body": "Stale", "new_position": 1}],
},
)
assert response.status_code == 422
assert response.json() == {
"error": "An inline comment no longer matches this pull request. Refresh the review."
}
@pytest.mark.anyio
async def test_review_submission_rejects_unsupported_decision_before_upstream(monkeypatch):
called = False
+11
View File
@@ -44,3 +44,14 @@ async def test_dashboard_announces_failed_refresh_and_retry_without_blanking_pan
assert "snapshot.freshness?.degraded" in html
assert "Refresh failed · showing last known data" in html
assert "snapshot.freshness.retry_in_seconds" in html
@pytest.mark.anyio
async def test_dashboard_reports_each_live_section_from_its_own_freshness():
html = await dashboard()
assert "snapshot.freshness?.sections?.notifications" in html
assert "snapshot.freshness?.sections?.events" in html
assert "const notificationsFresh = Array.isArray(snapshot.notifications)" not in html
assert "Unread updates unavailable · showing last known updates" in html
assert "Activity refresh failed · showing last activity" in html
+60
View File
@@ -0,0 +1,60 @@
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable(monkeypatch):
requested = []
async def page_loader(stream, page):
requested.append((stream, page))
return {
"stream": stream,
"page": page,
"total": 84,
"has_more": False,
"items": [{
"id": 51, "number": 51, "title": "Older issue", "state": "open",
"labels": [], "assignees": [{"login": "timmy"}],
"repository": {"full_name": "stackchain/api"},
"updated_at": "2026-08-07T10:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/51",
}],
}
monkeypatch.setattr(main.gitea_proxy, "work_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/work/issue?page=2")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("issue", 2)]
assert response.json() == {
"stream": "issue", "page": 2, "total": 84, "has_more": False,
"items": [{
"id": 51, "number": 51, "title": "Older issue", "state": "open",
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
"updated_at": "2026-08-07T10:00:00Z",
"url": "https://forge.example/stackchain/api/issues/51",
}],
}
@pytest.mark.anyio
async def test_work_page_endpoint_rejects_unknown_stream_before_upstream_io(monkeypatch):
called = False
async def page_loader(stream, page):
nonlocal called
called = True
monkeypatch.setattr(main.gitea_proxy, "work_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/work/unknown?page=2")
assert response.status_code == 422
assert called is False