Compare commits

...
Author SHA1 Message Date
timmy 174dac05b9 feat: make assigned pull sheets read-first (#217)
CI / lint (pull_request) Successful in 20s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 18:00:00 +00:00
rockachopa ea1f4ad837 Make mobile issue sheets read-first with on-demand planning (#216)
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Closes #215
2026-08-07 17:39:18 +00:00
timmy d09ceb8650 feat: make mobile issue sheets read-first (#215)
CI / lint (pull_request) Successful in 18s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 17:38:01 +00:00
timmy c0cc6ec46e Merge pull request 'Keep Find Work instant while its catalog refreshes' (#214) from timmy/213-instant-find-work into main
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 17:16:30 +00:00
timmy aaff109ede feat: keep Find Work instant during refresh (#213)
CI / lint (pull_request) Successful in 18s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 17:15:31 +00:00
timmy 2fe9b6cc3d Merge pull request 'Recover unfinished mobile drafts from My Work' (#212) from timmy/211-mobile-draft-recovery into main
CI / lint (push) Successful in 18s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 16:56:08 +00:00
timmy a08ce31858 feat: recover mobile drafts from My Work (#211)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 16:55:00 +00:00
timmy 092234af38 Merge pull request 'Load complete mobile conversations without leaving My Work' (#210) from timmy/209-mobile-conversation-history into main
CI / lint (push) Successful in 17s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 16:23:32 +00:00
timmy c63b23865c feat: load complete mobile conversations (#209)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 16:22:30 +00:00
rockachopa e04d6753ac Merge pull request 'Resolve shared mobile My Work routes beyond page one' (#208) from timmy/207-resolve-mobile-work-routes into main
CI / lint (push) Successful in 21s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-07 16:00:02 +00:00
timmy b5545e380e feat: resolve shared My Work routes
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 15:58:56 +00:00
15 changed files with 1831 additions and 160 deletions

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
function createConversationPager({ loadPage }) {
let state = { comments: [], page: 1, older_page: null, total: 0 };
let olderRequest = null;
const validComments = comments => Array.isArray(comments)
? comments.filter(comment => comment && Number.isInteger(comment.id))
: [];
const unique = comments => {
const seen = new Set();
return comments.filter(comment => {
if (seen.has(comment.id)) return false;
seen.add(comment.id);
return true;
}).sort((left, right) => {
const leftTime = Date.parse(left.created_at || '');
const rightTime = Date.parse(right.created_at || '');
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return left.id - right.id;
});
};
const snapshot = () => ({ ...state, comments: state.comments.map(comment => ({ ...comment })) });
return {
reset(page) {
const comments = unique(validComments(page?.comments));
state = {
comments,
page: Number.isInteger(page?.page) ? page.page : 1,
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
total: Number.isInteger(page?.total) ? Math.max(page.total, comments.length) : comments.length,
};
olderRequest = null;
return snapshot();
},
snapshot,
loadOlder() {
if (olderRequest) return olderRequest;
if (!Number.isInteger(state.older_page)) return Promise.resolve(snapshot());
const requestedPage = state.older_page;
olderRequest = Promise.resolve(loadPage(requestedPage)).then(page => {
state = {
comments: unique(validComments(page?.comments).concat(state.comments)),
page: Number.isInteger(page?.page) ? page.page : requestedPage,
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
total: Number.isInteger(page?.total) ? Math.max(page.total, state.comments.length) : state.total,
};
return snapshot();
}).finally(() => { olderRequest = null; });
return olderRequest;
},
append(comment) {
if (!comment || !Number.isInteger(comment.id)) return snapshot();
if (!state.comments.some(existing => existing.id === comment.id)) {
state = {
...state,
comments: state.comments.concat(comment),
total: Math.max(state.total + 1, state.comments.length + 1),
};
}
return snapshot();
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationPager;
+144
View File
@@ -0,0 +1,144 @@
function createDraftInbox({ storage, now = () => Date.now() }) {
const indexKey = 'stackchain.draft-index.v1';
function readIndex() {
try {
const value = JSON.parse(storage?.getItem(indexKey) || '{}');
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch (_error) { return {}; }
}
function writeIndex(index) {
try { storage?.setItem(indexKey, JSON.stringify(index)); }
catch (_error) { /* Draft discovery remains available without metadata. */ }
}
function keys() {
try {
return Array.from({ length: Number(storage?.length || 0) }, (_, index) => storage.key(index))
.filter(Boolean);
} catch (_error) { return []; }
}
function textPreview(value) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 180);
}
function parseTarget(value) {
const match = String(value || '').match(/^(.+)#(\d+)$/);
return match ? { repository: match[1], number: Number(match[2]) } : null;
}
function parse(key, raw) {
let match;
if (key === 'stackchain.issue-capture.v1') {
try {
const value = JSON.parse(raw);
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
if (!preview && !value?.repository) return null;
return {
kind: 'new-issue', label: 'New issue', repository: String(value?.repository || ''),
title: textPreview(value?.title) || 'Untitled new issue', preview,
};
} catch (_error) { return null; }
}
match = key.match(/^stackchain\.(issue|pull)-comment\.v1:(.+#\d+)$/);
if (match) {
const target = parseTarget(match[2]);
const preview = textPreview(raw);
if (!target || !preview) return null;
const routeKind = match[1] === 'issue' ? 'issue' : 'pull';
return {
kind: routeKind + '-comment', label: routeKind === 'issue' ? 'Issue comment' : 'PR comment',
...target, title: target.repository + '#' + target.number, preview,
route: { kind: routeKind, ...target },
};
}
match = key.match(/^stackchain\.issue-content\.v1:(.+#\d+)$/);
if (match) {
const target = parseTarget(match[1]);
try {
const value = JSON.parse(raw);
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
if (!target || !preview || typeof value?.expectedUpdatedAt !== 'string') return null;
return {
kind: 'issue-edit', label: 'Issue edit', ...target,
title: target.repository + '#' + target.number, preview,
route: { kind: 'issue', ...target },
};
} catch (_error) { return null; }
}
match = key.match(/^stackchain\.update-reply\.v1\.(\d+)$/);
if (match) {
const preview = textPreview(raw);
if (!preview) return null;
const notificationId = Number(match[1]);
return {
kind: 'update-reply', label: 'Update reply', notification_id: notificationId,
title: 'Update #' + notificationId, preview,
route: { kind: 'update', notification_id: notificationId },
};
}
match = key.match(/^stackchain\.review-draft\.v1:(.+#\d+)@([^:]+)$/);
if (match) {
const target = parseTarget(match[1]);
try {
const value = JSON.parse(raw);
const notes = Object.values(value?.notes || {});
const comments = Array.isArray(value?.comments) ? value.comments.map(item => item?.body) : [];
const preview = textPreview([value?.summary, ...notes, ...comments].filter(Boolean).join(' — '));
if (!target || !preview) return null;
return {
kind: 'review', label: 'PR review', ...target, head_sha: match[2],
title: target.repository + '#' + target.number, preview,
route: { kind: 'review', ...target },
};
} catch (_error) { return null; }
}
return null;
}
function list() {
const index = readIndex();
const seen = new Set();
const drafts = [];
keys().forEach(key => {
if (key === indexKey || key.endsWith(':operation')) return;
let raw;
try { raw = storage.getItem(key); }
catch (_error) { return; }
const parsed = parse(key, raw);
if (!parsed) return;
seen.add(key);
const fingerprint = String(raw);
const previous = index[key];
if (!previous || previous.fingerprint !== fingerprint) {
index[key] = { fingerprint, updated_at: Number(now()) };
}
drafts.push({ id: key, ...parsed, updated_at: index[key].updated_at });
});
Object.keys(index).forEach(key => { if (!seen.has(key)) delete index[key]; });
writeIndex(index);
return drafts.sort((left, right) =>
Number(right.updated_at || 0) - Number(left.updated_at || 0) || left.id.localeCompare(right.id)
);
}
function discard(id) {
if (typeof id !== 'string' || !parse(id, (() => {
try { return storage?.getItem(id); } catch (_error) { return null; }
})())) return false;
try {
storage?.removeItem(id);
storage?.removeItem(id + ':operation');
const index = readIndex();
delete index[id];
writeIndex(index);
return true;
} catch (_error) { return false; }
}
return { list, discard };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
+322 -111
View File
@@ -65,6 +65,10 @@ textarea { resize: vertical; min-height: 120px; }
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
.draft-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.draft-actions button { min-height:44px; width:100%; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
@@ -76,6 +80,8 @@ textarea { resize: vertical; min-height: 120px; }
.load-more-notifications[hidden] { display:none; }
.load-more-work { min-height:44px; width:100%; margin-top:10px; }
.load-more-work[hidden] { display:none; }
.retry-work-route { min-height:44px; width:100%; margin-top:10px; }
.retry-work-route[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; }
@@ -136,6 +142,9 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.issue-sheet-header button { min-height:44px; }
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
.issue-planning-retry { min-height:44px; width:100%; }
#edit-issue-content { min-height:44px; width:100%; }
.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; }
.issue-edit-form label { display:grid; gap:6px; min-width:0; }
@@ -210,6 +219,7 @@ textarea { resize: vertical; min-height: 120px; }
.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; }
.conversation-more { min-height:44px; width:100%; margin:8px 0; }
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
.pull-review-file { margin-top:8px; }
@@ -221,6 +231,10 @@ textarea { resize: vertical; min-height: 120px; }
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
.pull-review-tools button { min-height:44px; }
.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; }
.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
.pull-review summary h2 { margin:0; font-size:16px; }
.pull-review > #merge-pull { min-height:44px; width:100%; margin-top: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; }
@@ -273,6 +287,7 @@ textarea { resize: vertical; min-height: 120px; }
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
<button class="work-filter" data-work-filter="draft" aria-pressed="false">Drafts <span data-work-count="draft">0</span></button>
</div>
<label class="milestone-lane" for="work-milestone-filter"><span class="small">Release lane</span>
<select class="work-milestone-filter" id="work-milestone-filter">
@@ -287,6 +302,7 @@ textarea { resize: vertical; min-height: 120px; }
<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>
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
<div class="small" id="work-route-share-status" aria-live="polite"></div>
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
<button id="bulk-mark-read" type="button"></button>
@@ -408,52 +424,58 @@ 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>Reload latest 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>
<fieldset class="issue-due-editor" aria-describedby="issue-due-status">
<legend>Due date</legend>
<label for="issue-due-date" class="small">Schedule this assigned issue</label>
<input id="issue-due-date" type="date" disabled />
<div class="issue-due-actions">
<button id="save-issue-due-date" type="button" disabled>Save due date</button>
<button id="clear-issue-due-date" type="button" disabled>Clear due date</button>
</div>
<div id="issue-due-status" class="small" aria-live="assertive">Load an issue to schedule it.</div>
</fieldset>
<fieldset class="issue-milestone-editor" aria-describedby="issue-milestone-status">
<legend>Milestone</legend>
<label for="issue-milestone" class="small">Plan this issue into a release</label>
<select id="issue-milestone" disabled><option value="">No milestone</option></select>
<button id="save-issue-milestone" type="button" disabled>Save milestone</button>
<div id="issue-milestone-status" class="small" aria-live="assertive">Load an issue to plan it.</div>
</fieldset>
<p class="issue-sheet-content" id="issue-sheet-body"></p>
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
<form class="issue-edit-form" id="issue-edit-form" hidden>
<label for="issue-edit-title">Title
<input id="issue-edit-title" type="text" maxlength="255" required autocomplete="off" />
</label>
<label for="issue-edit-body">Description
<textarea id="issue-edit-body" maxlength="10000"></textarea>
</label>
<div class="issue-edit-actions">
<button id="save-issue-content" type="submit">Save issue</button>
<button id="cancel-issue-content" type="button">Cancel</button>
</div>
<div id="issue-edit-status" class="small" aria-live="assertive"></div>
</form>
<h2>Recent discussion</h2>
<h2>Full conversation</h2>
<div id="issue-comments"></div>
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
<div id="issue-conversation-status" class="small" aria-live="assertive"></div>
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
<h2 id="issue-comment-title">Add comment</h2>
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-issue-comment" type="button">Post comment</button>
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
</section>
<details class="issue-planning" id="issue-planning">
<summary>Plan &amp; edit</summary>
<button class="issue-planning-retry" id="retry-issue-planning" type="button" hidden>Retry planning controls</button>
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
<form class="issue-edit-form" id="issue-edit-form" hidden>
<label for="issue-edit-title">Title
<input id="issue-edit-title" type="text" maxlength="255" required autocomplete="off" />
</label>
<label for="issue-edit-body">Description
<textarea id="issue-edit-body" maxlength="10000"></textarea>
</label>
<div class="issue-edit-actions">
<button id="save-issue-content" type="submit">Save issue</button>
<button id="cancel-issue-content" type="button">Cancel</button>
</div>
<div id="issue-edit-status" class="small" aria-live="assertive"></div>
</form>
<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">Expand planning controls to load labels.</div>
<button id="save-issue-labels" type="button" disabled>Save labels</button>
</fieldset>
<fieldset class="issue-due-editor" aria-describedby="issue-due-status">
<legend>Due date</legend>
<label for="issue-due-date" class="small">Schedule this assigned issue</label>
<input id="issue-due-date" type="date" disabled />
<div class="issue-due-actions">
<button id="save-issue-due-date" type="button" disabled>Save due date</button>
<button id="clear-issue-due-date" type="button" disabled>Clear due date</button>
</div>
<div id="issue-due-status" class="small" aria-live="assertive">Load an issue to schedule it.</div>
</fieldset>
<fieldset class="issue-milestone-editor" aria-describedby="issue-milestone-status">
<legend>Milestone</legend>
<label for="issue-milestone" class="small">Plan this issue into a release</label>
<select id="issue-milestone" disabled><option value="">No milestone</option></select>
<button id="save-issue-milestone" type="button" disabled>Save milestone</button>
<div id="issue-milestone-status" class="small" aria-live="assertive">Expand planning controls to load milestones.</div>
</fieldset>
</details>
<div class="issue-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<button id="release-issue" type="button">Release assignment</button>
@@ -561,21 +583,27 @@ textarea { resize: vertical; min-height: 120px; }
</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 class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
<div id="pull-files"></div>
<h2>Recent discussion</h2><div id="pull-comments"></div>
<h2>Full conversation</h2><div id="pull-comments"></div>
<button class="conversation-more" id="load-older-pull-comments" type="button" hidden>Load older messages</button>
<div id="pull-conversation-status" class="small" aria-live="assertive"></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>
<details class="pull-review" id="pull-review">
<summary><h2>Review &amp; merge</h2></summary>
<div id="pull-review-status" class="small" aria-live="polite">Expand to load changed files and merge readiness.</div>
<button class="pull-retry" id="pull-review-retry" type="button" hidden>Retry review data</button>
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Review data not loaded</span></div>
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
<div id="pull-files"></div>
<button id="merge-pull" type="button" disabled>Merge</button>
</details>
<div class="pull-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<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>
<nav class="work-session-nav" aria-label="Work session" hidden>
@@ -652,8 +680,10 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/commands.js"></script>
<script src="static/search-preview.js"></script>
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/my-work.js"></script>
<script src="static/pick-work.js"></script>
<script src="static/conversation.js"></script>
<script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script>
<script src="static/pull-sheet.js"></script>
@@ -692,13 +722,14 @@ textarea { resize: vertical; min-height: 120px; }
let selectedWorkMilestone = 'all';
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
if (['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)) selectedWorkFilter = savedFilter;
if (['all', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)) selectedWorkFilter = savedFilter;
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
if (savedMilestone) selectedWorkMilestone = savedMilestone;
} catch (e) {
console.warn('Could not restore My Work filter', e);
}
let lastMyWork = [];
let lastDrafts = [];
let lastNotifications = [];
let lastContextSnapshot = null;
let notificationPagination = { page: 1, total: 0, has_more: false };
@@ -710,10 +741,12 @@ textarea { resize: vertical; min-height: 120px; }
let updateTrigger = null;
let selectedIssue = null;
let selectedIssueDetail = null;
let issueConversation = null;
let issueTrigger = null;
let selectedPull = null;
let pullTrigger = null;
let selectedPullDetail = null;
let pullConversation = null;
let pullReviewState = null;
let creatingIssue = false;
let findingWork = false;
@@ -733,10 +766,26 @@ textarea { resize: vertical; min-height: 120px; }
if (!response.ok) throw new Error(payload.error || 'Review request failed.');
return payload;
}
async function api(url) {
const response = await fetch(url, { headers: { Accept: 'application/json' } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error || payload.detail || 'Shared work request failed.');
error.unavailable = response.status === 404;
throw error;
}
return payload;
}
const reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const planningLoader = createIssueSheet.createPlanningLoader({
loadLabels: item => issueController.loadLabels(item),
loadMilestones: item => issueController.loadMilestones(item),
});
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const draftInbox = createDraftInbox({ storage: localStorage });
const findWorkController = createFindWork({
fetchJson: fetchReviewJson,
onItems: renderAvailableIssues,
@@ -942,7 +991,22 @@ textarea { resize: vertical; min-height: 120px; }
location: window.location,
history: window.history,
eventTarget: window,
resolve: route => {
const params = new URLSearchParams({ kind:route.kind });
if (route.kind === 'update') params.set('notification_id', route.notification_id);
else {
params.set('repository', route.repository);
params.set('number', route.number);
}
return api('api/v1/work-route?' + params.toString());
},
onResolving: () => {
qs('#retry-work-route').hidden = true;
qs('#my-work-action-status').textContent = 'Loading shared work item…';
},
onOpen: item => {
qs('#retry-work-route').hidden = true;
qs('#my-work-action-status').textContent = '';
closeOpenWorkSheets();
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
else if (item.kind === 'review') openReviewSheet(item, reviewTrigger);
@@ -956,13 +1020,19 @@ textarea { resize: vertical; min-height: 120px; }
if (selectedUpdate) closeUpdateSheet(true, false);
},
onInvalid: () => {
qs('#retry-work-route').hidden = true;
window.history.replaceState(null, '', window.location.pathname + window.location.search);
closeOpenWorkSheets();
qs('#my-work-action-status').textContent = 'Route unavailable · this item is no longer in My Work.';
qs('#my-work').scrollIntoView({ block:'start' });
},
onError: () => {
qs('#my-work-action-status').textContent = 'Could not load shared work item. The link is preserved; retry when connected.';
qs('#retry-work-route').hidden = false;
},
});
workRoute.start();
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
function closeOpenWorkSheets() {
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
@@ -1072,7 +1142,9 @@ textarea { resize: vertical; min-height: 120px; }
}
function refreshMyWorkView() {
lastDrafts = draftInbox.list();
const counts = countMyWork(lastMyWork);
counts.draft = lastDrafts.length;
Object.entries(counts).forEach(([filter, count]) => {
const element = qs('[data-work-count="' + filter + '"]');
if (element) element.textContent = count;
@@ -1107,6 +1179,38 @@ textarea { resize: vertical; min-height: 120px; }
return [];
}
function renderDrafts() {
const list = qs('#my-work-list');
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) =>
'<article class="my-work-card draft-card">' +
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' +
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
'<div class="draft-actions"><button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' +
'<button class="draft-discard" data-draft-id="' + escAttr(item.id) + '" type="button">Discard draft</button></div></article>'
).join('') : '<div class="muted">No unfinished drafts.</div>';
list.querySelectorAll('.draft-resume').forEach(button => {
button.addEventListener('click', () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item) return;
if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.route) workRoute.open(item.route);
});
});
list.querySelectorAll('.draft-discard').forEach(button => {
button.addEventListener('click', () => {
if (!window.confirm('Discard this unfinished draft?')) return;
draftInbox.discard(button.dataset.draftId);
lastDrafts = draftInbox.list();
const count = qs('[data-work-count="draft"]');
if (count) count.textContent = lastDrafts.length;
renderDrafts();
qs('#my-work-action-status').textContent = 'Draft discarded.';
});
});
}
function updateWorkPaginationControls() {
const labels = { issue: 'issues', pull: 'pull requests', review: 'review requests' };
const streams = activeWorkStreams();
@@ -1123,6 +1227,15 @@ textarea { resize: vertical; min-height: 120px; }
}
function renderMyWork() {
lastDrafts = draftInbox.list();
const draftCount = qs('[data-work-count="draft"]');
if (draftCount) draftCount.textContent = lastDrafts.length;
if (selectedWorkFilter === 'draft') {
renderDrafts();
qs('#bulk-mark-read-bar').hidden = true;
qs('#load-more-notifications').hidden = true;
return;
}
const visible = filterMyWork(lastMyWork, selectedWorkFilter, selectedWorkMilestone);
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
@@ -1236,36 +1349,47 @@ textarea { resize: vertical; min-height: 120px; }
}
function renderIssueComment(comment) {
return '<div class="issue-comment"><div class="small">' +
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
'</div><div class="issue-sheet-content">' +
escapeHtml(comment.body || 'No comment body provided.') + '</div></div>';
}
async function loadIssueLabelEditor(item, confirmedNames) {
function renderIssueConversation(state) {
const comments = state?.comments || [];
qs('#issue-comments').innerHTML = comments.length ?
comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-issue-comments').hidden = !Number.isInteger(state?.older_page);
qs('#issue-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
function renderPullConversation(state) {
const comments = state?.comments || [];
qs('#pull-comments').innerHTML = comments.length ? comments.map(comment =>
'<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>'
).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-pull-comments').hidden = !Number.isInteger(state?.older_page);
qs('#pull-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
function renderIssueLabelEditor(item, confirmedNames, labels) {
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.';
}
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;
}
function selectedEditIssueLabelIds() {
@@ -1273,27 +1397,38 @@ textarea { resize: vertical; min-height: 120px; }
.map(input => Number(input.value)).filter(Number.isInteger);
}
async function loadIssueMilestoneEditor(item, confirmedMilestone) {
function renderIssueMilestoneEditor(item, confirmedMilestone, milestones) {
const select = qs('#issue-milestone');
const status = qs('#issue-milestone-status');
select.innerHTML = '<option value="">No milestone</option>';
select.disabled = true;
qs('#save-issue-milestone').disabled = true;
status.textContent = 'Loading milestones…';
select.innerHTML += milestones.map(milestone =>
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
).join('');
const draft = issueController.loadMilestoneDraft(item);
select.value = String(draft ?? confirmedMilestone?.id ?? '');
select.disabled = false;
qs('#save-issue-milestone').disabled = false;
status.textContent = confirmedMilestone ?
'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.';
}
async function loadIssuePlanning() {
const item = selectedIssue;
const detail = selectedIssueDetail;
if (!item || !detail) return;
qs('#retry-issue-planning').hidden = true;
qs('#issue-label-status').textContent = 'Loading labels…';
qs('#issue-milestone-status').textContent = 'Loading milestones…';
try {
const milestones = await issueController.loadMilestones(item);
const planning = await planningLoader.open(selectedIssue);
if (selectedIssue !== item) return;
select.innerHTML += milestones.map(milestone =>
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
).join('');
const draft = issueController.loadMilestoneDraft(item);
select.value = String(draft ?? confirmedMilestone?.id ?? '');
select.disabled = false;
qs('#save-issue-milestone').disabled = false;
status.textContent = confirmedMilestone ?
'Planned for ' + confirmedMilestone.title + '.' : 'No milestone set.';
renderIssueLabelEditor(item, detail.labels || [], planning.labels);
renderIssueMilestoneEditor(item, detail.milestone, planning.milestones);
} catch (_error) {
status.textContent = 'Milestones could not be loaded. Retry by reopening this issue.';
if (selectedIssue !== item) return;
qs('#issue-label-status').textContent = 'Labels could not be loaded.';
qs('#issue-milestone-status').textContent = 'Milestones could not be loaded.';
qs('#retry-issue-planning').hidden = false;
}
}
@@ -1301,6 +1436,7 @@ textarea { resize: vertical; min-height: 120px; }
if (!item) return;
selectedIssue = item;
selectedIssueDetail = null;
issueConversation = null;
issueTrigger = trigger;
qs('#issue-sheet').classList.add('open');
qs('#issue-sheet-key').textContent = item.key || '';
@@ -1310,10 +1446,14 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-labels').textContent = '';
qs('#issue-assignees').textContent = '';
qs('#issue-comments').textContent = '';
qs('#load-older-issue-comments').hidden = true;
qs('#issue-conversation-status').textContent = 'Loading newest messages…';
qs('#issue-comment').value = issueController.loadDraft(item);
qs('#issue-comment-status').textContent = '';
qs('#issue-planning').open = false;
qs('#retry-issue-planning').hidden = true;
qs('#issue-label-list').textContent = '';
qs('#issue-label-status').textContent = 'Loading labels…';
qs('#issue-label-status').textContent = 'Expand planning controls to load labels.';
qs('#save-issue-labels').disabled = true;
qs('#issue-due-date').value = '';
qs('#issue-due-date').disabled = true;
@@ -1323,7 +1463,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-milestone').innerHTML = '<option value="">No milestone</option>';
qs('#issue-milestone').disabled = true;
qs('#save-issue-milestone').disabled = true;
qs('#issue-milestone-status').textContent = 'Loading milestones…';
qs('#issue-milestone-status').textContent = 'Expand planning controls to load milestones.';
qs('#retry-issue-load').hidden = true;
qs('#open-issue-gitea').href = item.url || '#';
qs('#send-issue-comment').disabled = false;
@@ -1336,17 +1476,15 @@ textarea { resize: vertical; min-height: 120px; }
const detail = await issueController.load(item);
if (selectedIssue !== item) return;
selectedIssueDetail = detail;
issueConversation = issueController.conversation(item, detail.conversation);
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
qs('#issue-sheet-body').textContent = detail.body || 'No description provided.';
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
'<span class="pill">' + escapeHtml(label) + '</span>'
).join(' ');
loadIssueLabelEditor(item, detail.labels || []);
loadIssueMilestoneEditor(item, detail.milestone);
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
qs('#issue-comments').innerHTML = (detail.comments || []).length ?
detail.comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
renderIssueConversation(issueConversation.snapshot());
qs('#open-issue-gitea').href = detail.url || item.url || '#';
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
qs('#edit-issue-content').disabled = false;
@@ -1357,6 +1495,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#clear-issue-due-date').disabled = !detail.due_date;
qs('#issue-due-status').textContent = detail.due_date ?
'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.';
if (qs('#issue-planning').open) loadIssuePlanning();
} catch (error) {
if (selectedIssue !== item) return;
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
@@ -1373,6 +1512,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-sheet').classList.remove('open');
selectedIssue = null;
selectedIssueDetail = null;
issueConversation = null;
if (issueTrigger?.isConnected) issueTrigger.focus();
}
@@ -1423,11 +1563,36 @@ textarea { resize: vertical; min-height: 120px; }
}
}
async function loadPullReview() {
if (!selectedPull || !selectedPullDetail?.head_sha) return;
const item = selectedPull;
const readDetail = selectedPullDetail;
qs('#pull-review-status').textContent = 'Loading changed files and merge readiness…';
qs('#pull-review-retry').hidden = true;
qs('#merge-pull').disabled = true;
try {
const review = await pullController.loadReview(item, readDetail.head_sha);
if (selectedPull !== item) return;
selectedPullDetail = { ...readDetail, ...review };
qs('#pull-ci-state').textContent = 'CI ' + (review.ci_state || 'unknown');
renderPullReview(selectedPullDetail);
qs('#pull-review-status').textContent = review.head_sha === readDetail.head_sha ?
'Review data ready for the current head.' : 'New commits detected. Review progress restarted for the latest head.';
} catch (error) {
if (selectedPull !== item) return;
qs('#pull-review-status').textContent = error.message + ' Reading and replies remain available; retry here.';
qs('#pull-review-retry').hidden = false;
qs('#pull-merge-state').textContent = 'Review data unavailable';
qs('#merge-pull').disabled = true;
}
}
async function openPullSheet(item, trigger) {
if (!item) return;
selectedPull = item;
pullTrigger = trigger;
selectedPullDetail = null;
pullConversation = null;
pullReviewState = null;
qs('#pull-sheet').classList.add('open');
qs('#pull-sheet-key').textContent = item.key || '';
@@ -1435,13 +1600,18 @@ textarea { resize: vertical; min-height: 120px; }
qs('#pull-sheet-status').textContent = 'Loading pull request…';
qs('#pull-sheet-body').textContent = '';
qs('#pull-files').textContent = '';
qs('#pull-review-progress').textContent = 'Loading review progress…';
qs('#pull-review').open = false;
qs('#pull-review-status').textContent = 'Expand to load changed files and merge readiness.';
qs('#pull-review-retry').hidden = true;
qs('#pull-review-progress').textContent = 'Review progress unavailable.';
qs('#next-unreviewed-pull-file').disabled = true;
qs('#pull-comments').textContent = '';
qs('#load-older-pull-comments').hidden = true;
qs('#pull-conversation-status').textContent = 'Loading newest messages…';
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('#pull-merge-state').textContent = 'Review data not loaded';
qs('#merge-pull').disabled = true;
qs('#retry-pull-load').hidden = true;
qs('#open-pull-gitea').href = item.url || '#';
@@ -1450,13 +1620,10 @@ textarea { resize: vertical; min-height: 120px; }
const detail = await pullController.load(item);
if (selectedPull !== item) return;
selectedPullDetail = detail;
pullConversation = pullController.conversation(item, detail.conversation);
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');
renderPullReview(detail);
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>';
renderPullConversation(pullConversation.snapshot());
qs('#open-pull-gitea').href = detail.url || item.url || '#';
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
} catch (error) {
@@ -1475,6 +1642,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#pull-sheet').classList.remove('open');
selectedPull = null;
selectedPullDetail = null;
pullConversation = null;
pullReviewState = null;
if (pullTrigger?.isConnected) pullTrigger.focus();
}
@@ -1540,15 +1708,20 @@ textarea { resize: vertical; min-height: 120px; }
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…';
const retainedItems = findWorkController.items();
if (retainedItems.length) renderAvailableIssues(retainedItems);
else qs('#find-work-list').textContent = '';
qs('#find-work-status').textContent = retainedItems.length ?
'Refreshing available issues…' : '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.';
const result = await findWorkController.load();
if (!result?.stale) {
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.';
}
@@ -1594,13 +1767,14 @@ textarea { resize: vertical; min-height: 120px; }
function openCreateIssueSheet() {
const captureDraft = issueCapture.loadDraft();
const repositories = lastContextSnapshot?.repos || [];
qs('#create-issue-repository').innerHTML = repositories.map(repository =>
'<option value="' + escAttr(repository.full_name) + '">' + escapeHtml(repository.full_name) + '</option>'
).join('');
if (repositories.some(repository => repository.full_name === captureDraft.repository)) {
qs('#create-issue-repository').value = captureDraft.repository;
const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
if (captureDraft.repository && !repositories.includes(captureDraft.repository)) {
repositories.unshift(captureDraft.repository);
}
qs('#create-issue-repository').innerHTML = repositories.map(repository =>
'<option value="' + escAttr(repository) + '">' + escapeHtml(repository) + '</option>'
).join('');
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
qs('#create-issue-title').value = captureDraft.title;
qs('#create-issue-body').value = captureDraft.body;
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
@@ -2220,6 +2394,10 @@ textarea { resize: vertical; min-height: 120px; }
qs('#retry-issue-load').addEventListener('click', () => {
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
});
qs('#issue-planning').addEventListener('toggle', event => {
if (event.currentTarget.open) loadIssuePlanning();
});
qs('#retry-issue-planning').addEventListener('click', loadIssuePlanning);
qs('#edit-issue-content').addEventListener('click', () => {
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
const draft = issueController.loadEditDraft(selectedIssue) || {
@@ -2291,6 +2469,22 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-comment').addEventListener('input', event => {
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
});
qs('#load-older-issue-comments').addEventListener('click', async () => {
if (!issueConversation) return;
const button = qs('#load-older-issue-comments');
const panel = qs('#issue-sheet .issue-sheet-panel');
const previousHeight = panel.scrollHeight;
button.disabled = true;
qs('#issue-conversation-status').textContent = 'Loading older messages…';
try {
renderIssueConversation(await issueConversation.loadOlder());
panel.scrollTop += panel.scrollHeight - previousHeight;
} catch (error) {
qs('#issue-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#save-issue-labels').addEventListener('click', async () => {
if (!selectedIssue || !lastContextSnapshot) return;
const editing = selectedIssue;
@@ -2393,9 +2587,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-comment-status').textContent = 'Posting comment…';
try {
const comment = await issueController.comment(selectedIssue, body);
const empty = qs('#issue-comments .muted');
if (empty) empty.remove();
qs('#issue-comments').insertAdjacentHTML('beforeend', renderIssueComment(comment));
if (issueConversation) renderIssueConversation(issueConversation.append(comment));
qs('#issue-comment').value = '';
qs('#issue-comment-status').textContent = 'Comment posted.';
} catch (error) {
@@ -2450,7 +2642,27 @@ textarea { resize: vertical; min-height: 120px; }
qs('#retry-pull-load').addEventListener('click', () => {
if (selectedPull) openPullSheet(selectedPull, pullTrigger);
});
qs('#pull-review').addEventListener('toggle', event => {
if (event.currentTarget.open) loadPullReview();
});
qs('#pull-review-retry').addEventListener('click', loadPullReview);
qs('#next-unreviewed-pull-file').addEventListener('click', focusNextUnreviewedPullFile);
qs('#load-older-pull-comments').addEventListener('click', async () => {
if (!pullConversation) return;
const button = qs('#load-older-pull-comments');
const panel = qs('#pull-sheet .pull-sheet-panel');
const previousHeight = panel.scrollHeight;
button.disabled = true;
qs('#pull-conversation-status').textContent = 'Loading older messages…';
try {
renderPullConversation(await pullConversation.loadOlder());
panel.scrollTop += panel.scrollHeight - previousHeight;
} catch (error) {
qs('#pull-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#pull-comment').addEventListener('input', event => {
if (selectedPull) pullController.saveDraft(selectedPull, event.target.value);
});
@@ -2467,8 +2679,7 @@ textarea { resize: vertical; min-height: 120px; }
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>');
if (pullConversation) renderPullConversation(pullConversation.append(comment));
qs('#pull-comment').value = '';
qs('#pull-comment-status').textContent = 'Comment posted.';
} catch (error) {
+32 -1
View File
@@ -1,4 +1,24 @@
function createIssueSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function createPlanningLoader({ loadLabels, loadMilestones }) {
const requests = new Map();
const keyFor = item => item.repository + '#' + item.number;
return {
open(item) {
const key = keyFor(item);
if (requests.has(key)) return requests.get(key);
const request = Promise.all([loadLabels(item), loadMilestones(item)])
.then(([labels, milestones]) => ({ labels, milestones }))
.catch(error => {
if (requests.get(key) === request) requests.delete(key);
throw error;
});
requests.set(key, request);
return request;
},
};
}
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
@@ -21,6 +41,15 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
headers: { Accept: 'application/json' },
});
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
}),
});
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
return pager;
},
loadLabels(item) {
return fetchJson(issuePath(item) + '/labels', {
headers: { Accept: 'application/json' },
@@ -204,4 +233,6 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
};
}
createIssueSheet.createPlanningLoader = createPlanningLoader;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
+5
View File
@@ -39,6 +39,11 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
headers: { Accept: 'application/json' },
}).then(result => {
apply(result, append);
if (result?.refresh_failed === true) {
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
} else if (result?.revalidating === true) {
onStatus('Showing saved available work while the catalog refreshes…');
}
return result;
}).finally(() => { loadRequest = null; });
return loadRequest;
+26 -1
View File
@@ -37,9 +37,11 @@ function renderFile(file, index, reviewed, escapeHtml) {
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
}
function createPullSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
const reviewRequests = new Map();
const reviewCache = new Map();
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;
@@ -62,6 +64,29 @@ function createPullSheet({ fetchJson, storage, createOperationId = () => globalT
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
loadReview(item, headSha) {
const key = item.repository + '#' + item.number + ':' + headSha;
if (reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
if (reviewRequests.has(key)) return reviewRequests.get(key);
const request = fetchJson(pathFor(item) + '/review-data', {
headers: { Accept: 'application/json' },
}).then(detail => {
const confirmedKey = item.repository + '#' + item.number + ':' + detail.head_sha;
reviewCache.set(confirmedKey, detail);
return detail;
}).finally(() => reviewRequests.delete(key));
reviewRequests.set(key, request);
return request;
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
headers: { Accept: 'application/json' },
}),
});
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
return pager;
},
reviewState,
toggleReviewed(item, detail, filename) {
const state = reviewState(item, detail);
+39 -2
View File
@@ -46,31 +46,68 @@
Number(item.number) === route.number;
}
function createController({ location, history, eventTarget, onOpen, onClose, onInvalid }) {
function createController({
location, history, eventTarget, onOpen, onClose, onInvalid,
resolve, onResolving = function () {}, onError = function () {},
}) {
let items = [];
let started = false;
let active = '';
let ready = false;
let resolving = '';
let resolution = 0;
function resolveMissing(fragment, route) {
if (typeof resolve !== 'function') {
onInvalid();
return;
}
if (resolving === fragment) return;
resolving = fragment;
const request = ++resolution;
onResolving(route);
Promise.resolve(resolve(route)).then(item => {
if (request !== resolution || String(location.hash || '') !== fragment) return;
resolving = '';
if (!item || !sameRoute(item, route)) {
onInvalid();
return;
}
active = fragment;
onOpen({ ...item, kind: route.kind });
}).catch(error => {
if (request !== resolution || String(location.hash || '') !== fragment) return;
resolving = '';
if (error?.unavailable) onInvalid();
else onError(error, route);
});
}
function sync() {
const fragment = String(location.hash || '');
if (!fragment) {
resolution += 1;
resolving = '';
if (active) onClose();
active = '';
return;
}
const route = parse(fragment);
if (!route) {
resolution += 1;
resolving = '';
if (fragment.startsWith('#/my-work/')) onInvalid();
active = '';
return;
}
const item = items.find(candidate => sameRoute(candidate, route));
if (!item) {
if (ready) onInvalid();
if (ready) resolveMissing(fragment, route);
return;
}
if (active === fragment) return;
resolution += 1;
resolving = '';
active = fragment;
onOpen({ ...item, kind: route.kind });
}
+168 -35
View File
@@ -11,6 +11,7 @@ GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000").rstrip("/")
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
REVIEW_DIFF_MAX_BYTES = 64 * 1024
REVIEW_DIFF_MAX_LINES = 400
AVAILABLE_ISSUE_PAGE_CONCURRENCY = 3
_client: httpx.AsyncClient | None = None
@@ -22,6 +23,10 @@ class WorkItems(list[dict]):
self.pagination = pagination
class WorkRouteUnavailableError(ValueError):
"""Raised when a shared route no longer belongs in the current user's queue."""
class StaleReviewError(ValueError):
"""Raised before mutation when a pull request head changed during review."""
@@ -324,8 +329,8 @@ async def available_issue_snapshot(max_pages: int = 10, upstream_limit: int = 50
"""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):
async def load_page(upstream_page: int) -> tuple[list[Any], int | None]:
response = await _get_client().get(
"/api/v1/repos/issues/search",
headers=_auth(),
@@ -338,21 +343,41 @@ async def available_issue_snapshot(max_pages: int = 10, upstream_limit: int = 50
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
try:
total = max(0, int(response.headers["X-Total-Count"]))
except (KeyError, TypeError, ValueError):
total = None
return payload, total
first_payload, upstream_total = await load_page(1)
pages: list[list[Any]] = [first_payload]
if upstream_total is not None:
page_count = min(max_pages, max(1, (upstream_total + upstream_limit - 1) // upstream_limit))
semaphore = asyncio.Semaphore(AVAILABLE_ISSUE_PAGE_CONCURRENCY)
async def load_bounded(page: int) -> list[Any]:
async with semaphore:
payload, _ = await load_page(page)
return payload
if page_count > 1:
pages.extend(await asyncio.gather(*(
load_bounded(page) for page in range(2, page_count + 1)
)))
else:
previous = first_payload
for page in range(2, max_pages + 1):
if not previous or len(previous) < upstream_limit:
break
previous, _ = await load_page(page)
pages.append(previous)
for payload in pages:
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))
@@ -938,10 +963,58 @@ async def update_assigned_issue_milestone(
}
async def issue_conversation_page(
repository: str,
number: int,
page: int | None = None,
limit: int = 20,
) -> dict:
"""Return one bounded comment page, opening on the newest page by default."""
bounded_limit = min(50, max(1, limit))
requested_page = max(1, page or 1)
path = f"/api/v1/repos/{repository}/issues/{number}/comments"
async def load(selected_page: int) -> tuple[list, int]:
response = await _get_client().get(
path,
headers=_auth(),
params={"limit": bounded_limit, "page": selected_page},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea issue comments response was not a list")
comments = [item for item in payload if isinstance(item, dict)]
try:
total = max(len(comments), int(response.headers.get("X-Total-Count", len(comments))))
except (TypeError, ValueError):
total = len(comments)
return comments, total
comments, total = await load(requested_page)
newest_page = max(1, (total + bounded_limit - 1) // bounded_limit)
if len(comments) >= total:
selected_page = newest_page if page is None else min(requested_page, newest_page)
start = (selected_page - 1) * bounded_limit
comments = comments[start:start + bounded_limit]
else:
selected_page = requested_page
if page is None and newest_page != requested_page:
selected_page = newest_page
comments, confirmed_total = await load(selected_page)
total = max(total, confirmed_total)
return {
"comments": [_normalize_issue_comment(item) for item in comments],
"page": selected_page,
"older_page": selected_page - 1 if selected_page > 1 else None,
"total": total,
}
async def issue_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/issues/{number}"
issue, comments = await asyncio.gather(
fetch(base), fetch(f"{base}/comments?limit=20&page=1")
issue, conversation = await asyncio.gather(
fetch(base), issue_conversation_page(repository, number)
)
if not isinstance(issue, dict):
raise ValueError("Gitea issue response was not an object")
@@ -949,12 +1022,7 @@ async def issue_detail(repository: str, number: int) -> dict:
labels: list = labels_value if isinstance(labels_value, list) else []
assignees_value = issue.get("assignees")
assignees: list = assignees_value if isinstance(assignees_value, list) else []
comments_value: list = comments if isinstance(comments, list) else []
normalized_comments = [
_normalize_issue_comment(comment)
for comment in comments_value
if isinstance(comment, dict)
]
normalized_comments = conversation["comments"]
return {
"repository": repository,
"number": number,
@@ -980,6 +1048,7 @@ async def issue_detail(repository: str, number: int) -> dict:
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
],
"comments": normalized_comments,
"conversation": conversation,
}
@@ -1040,6 +1109,57 @@ def _login_in_users(login: str, value: object) -> bool:
)
async def resolve_work_route(
kind: str,
repository: str | None,
number: int | None,
notification_id: int | None,
) -> dict:
if kind == "update":
if notification_id is None:
raise WorkRouteUnavailableError("Notification identity is missing")
thread = await fetch(f"notifications/threads/{notification_id}")
if not isinstance(thread, dict) or thread.get("unread") is not True:
raise WorkRouteUnavailableError("Notification is no longer unread")
detail = await notification_detail(notification_id)
return {
"kind": "update",
"notification_id": notification_id,
"has_update": True,
"repository": detail.get("repository", ""),
"title": detail.get("title", ""),
"url": detail.get("url", ""),
}
if repository is None or number is None or kind not in {"issue", "pull", "review"}:
raise WorkRouteUnavailableError("Work identity is missing")
target_path = "issues" if kind == "issue" else "pulls"
login, target = await _current_login_and_target(
f"repos/{repository}/{target_path}/{number}"
)
assigned = _login_in_users(login, target.get("assignees"))
requested = _login_in_users(login, target.get("requested_reviewers"))
eligible = (
target.get("state") == "open"
and (
(kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned)
or (kind == "pull" and assigned)
or (kind == "review" and requested)
)
)
if not eligible:
raise WorkRouteUnavailableError("Work item is no longer in My Work")
return {
"kind": kind,
"repository": repository,
"number": number,
"title": target.get("title", "") if isinstance(target.get("title"), str) else "",
"state": "open",
"url": _safe_web_url(target.get("html_url")),
**({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}),
}
async def is_assigned_issue(repository: str, number: int) -> bool:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
@@ -1102,18 +1222,9 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
conversation = await issue_conversation_page(repository, number)
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, diff_result = 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"),
fetch_text(
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
),
)
diff, diff_truncated = diff_result
previews = _diff_previews(diff, diff_truncated)
user = pull.get("user") if isinstance(pull.get("user"), dict) else {}
return {
"repository": repository,
@@ -1124,6 +1235,33 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
"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 "",
"conversation": conversation,
}
async def pull_completion_review(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
head_value = pull.get("head")
head: dict = head_value if isinstance(head_value, dict) else {}
sha_value = head.get("sha")
sha = sha_value if isinstance(sha_value, str) else ""
files, status, diff_result = await asyncio.gather(
fetch(f"{base}/files"),
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch_text(
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
),
)
diff, diff_truncated = diff_result
previews = _diff_previews(diff, diff_truncated)
return {
"repository": repository,
"number": number,
"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,
@@ -1147,11 +1285,6 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
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)
],
}
+148 -6
View File
@@ -76,6 +76,7 @@ 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
AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS = 5.0
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
_live_snapshot_task: asyncio.Task | None = None
_live_snapshot_value: dict | None = None
@@ -107,6 +108,7 @@ _idempotency_ledger = IdempotencyLedger(
_available_issue_snapshot_task: asyncio.Task | None = None
_available_issue_snapshot_value: list[dict] | None = None
_available_issue_snapshot_created_at: float | None = None
_available_issue_snapshot_retry_at: float | None = None
class ContextPayloadError(ValueError):
@@ -397,7 +399,7 @@ 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", "/api/v1/available-issues", "/api/v1/search"} or request.url.path.startswith("/api/v1/work/") or (
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route"} 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 (
@@ -547,6 +549,33 @@ async def global_search_preview(
return JSONResponse(preview)
@app.get("/api/v1/work-route")
async def resolve_work_route(
kind: Literal["issue", "pull", "review", "update"] = Query(),
repository: str | None = Query(default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"),
number: int | None = Query(default=None, gt=0),
notification_id: int | None = Query(default=None, gt=0),
) -> JSONResponse:
repository_route = repository is not None and number is not None and notification_id is None
update_route = kind == "update" and notification_id is not None and repository is None and number is None
if (kind == "update" and not update_route) or (kind != "update" and not repository_route):
raise HTTPException(status_code=422, detail="Invalid work route identity")
try:
result = await asyncio.wait_for(
gitea_proxy.resolve_work_route(kind, repository, number, notification_id),
timeout=WORK_PAGE_TIMEOUT_SECONDS,
)
except gitea_proxy.WorkRouteUnavailableError:
raise HTTPException(status_code=404, detail="Work item is no longer in My Work")
except Exception:
return JSONResponse(
{"error": "The shared work item is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/work/{stream}")
async def paged_work(
stream: Literal["issue", "pull", "review"],
@@ -573,13 +602,24 @@ async def paged_work(
async def _refresh_available_issue_snapshot() -> list[dict]:
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
global _available_issue_snapshot_retry_at
result = await gitea_proxy.available_issue_snapshot()
_available_issue_snapshot_value = result
_available_issue_snapshot_created_at = time.monotonic()
_available_issue_snapshot_retry_at = None
return result
async def _available_issue_snapshot() -> tuple[list[dict], bool]:
def _observe_available_issue_refresh(task: asyncio.Task) -> None:
global _available_issue_snapshot_retry_at
if not task.cancelled():
if task.exception() is not None:
_available_issue_snapshot_retry_at = (
time.monotonic() + AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS
)
async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
global _available_issue_snapshot_task
now = time.monotonic()
if (
@@ -588,16 +628,25 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool]:
and now - _available_issue_snapshot_created_at
< AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS
):
return _available_issue_snapshot_value, False
return _available_issue_snapshot_value, False, False, False
if (
_available_issue_snapshot_value is not None
and _available_issue_snapshot_retry_at is not None
and now < _available_issue_snapshot_retry_at
):
return _available_issue_snapshot_value, True, False, True
if _available_issue_snapshot_task is None or _available_issue_snapshot_task.done():
_available_issue_snapshot_task = asyncio.create_task(
_refresh_available_issue_snapshot()
)
_available_issue_snapshot_task.add_done_callback(_observe_available_issue_refresh)
if _available_issue_snapshot_value is not None:
return _available_issue_snapshot_value, True, True, False
try:
return await asyncio.shield(_available_issue_snapshot_task), False
return await asyncio.shield(_available_issue_snapshot_task), False, False, False
except Exception:
if _available_issue_snapshot_value is not None:
return _available_issue_snapshot_value, True
return _available_issue_snapshot_value, True, False, True
raise
@@ -615,7 +664,7 @@ def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict
@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(
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
)
except Exception:
@@ -627,6 +676,10 @@ async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONRe
result = _available_issue_page(items, page)
if stale:
result["stale"] = True
if revalidating:
result["revalidating"] = True
if refresh_failed:
result["refresh_failed"] = True
return JSONResponse(result)
@@ -1388,6 +1441,35 @@ async def create_assigned_issue(
return JSONResponse(result, status_code=201)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/comments")
async def assigned_issue_conversation(
owner: str,
repo: str,
number: int = PathParam(gt=0),
page: int | None = Query(default=None, ge=1, le=100),
limit: int = Query(default=20, ge=1, le=50),
):
repository = f"{owner}/{repo}"
async def load_conversation():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.issue_conversation_page(repository, number, page, limit)
try:
return await asyncio.wait_for(
load_conversation(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The conversation could not be loaded. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", status_code=201)
async def comment_on_assigned_issue(
comment: IssueComment,
@@ -1560,6 +1642,66 @@ async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
async def assigned_pull_review_data(
owner: str, repo: str, number: int = PathParam(gt=0)
):
repository = f"{owner}/{repo}"
async def load_assigned_pull_review():
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_review(repository, number)
try:
return await asyncio.wait_for(
load_assigned_pull_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except TimeoutError:
return JSONResponse(
{"error": "Loading review and merge data timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "Review and merge data is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments")
async def assigned_pull_conversation(
owner: str,
repo: str,
number: int = PathParam(gt=0),
page: int | None = Query(default=None, ge=1, le=100),
limit: int = Query(default=20, ge=1, le=50),
):
repository = f"{owner}/{repo}"
async def load_conversation():
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.issue_conversation_page(repository, number, page, limit)
try:
return await asyncio.wait_for(
load_conversation(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The conversation could not be loaded. Your draft is safe; 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,
+117
View File
@@ -0,0 +1,117 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_draft_inbox_discovers_existing_formats_orders_activity_and_ignores_corruption():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-capture.v1', JSON.stringify({{repository:'stackchain/api',title:'Ship recovery',body:'Detailed plan',labelIds:[]}})],
['stackchain.issue-comment.v1:stackchain/api#17', 'Please add a regression test'],
['stackchain.pull-comment.v1:stackchain/web#9', 'Looks good on mobile'],
['stackchain.update-reply.v1.44', 'I can take this'],
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{'app.js':'Handle offline state'}},comments:[],summary:'Needs one fix',decision:'request_changes'}})],
['stackchain.issue-content.v1:stackchain/api#18', '{{broken-json'],
['stackchain.issue-comment.v1:stackchain/api#20:operation', 'not-a-draft'],
]);
const storage = {{
get length() {{ return values.size; }},
key: index => Array.from(values.keys())[index] || null,
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
let clock = 1000;
const inbox = createDraftInbox({{storage, now:() => ++clock}});
const first = inbox.list();
values.set('stackchain.issue-comment.v1:stackchain/api#17', 'Newest comment text');
const second = inbox.list();
process.stdout.write(JSON.stringify({{
kinds:first.map(item => item.kind).sort(),
newest:second[0],
count:second.length,
}}));
"""
output = run_node(script)
assert output["kinds"] == ["issue-comment", "new-issue", "pull-comment", "review", "update-reply"]
assert output["count"] == 5
assert output["newest"]["kind"] == "issue-comment"
assert output["newest"]["repository"] == "stackchain/api"
assert output["newest"]["number"] == 17
assert output["newest"]["preview"] == "Newest comment text"
assert output["newest"]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 17}
def test_draft_discard_removes_only_selected_content_and_operation_identity():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-comment.v1:stackchain/api#17', 'One'],
['stackchain.issue-comment.v1:stackchain/api#17:operation', 'operation-one'],
['stackchain.pull-comment.v1:stackchain/web#9', 'Two'],
]);
const storage = {{
get length() {{ return values.size; }}, key:i => Array.from(values.keys())[i] || null,
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key),
}};
const inbox = createDraftInbox({{storage, now:() => 1000}});
const selected = inbox.list().find(item => item.kind === 'issue-comment');
inbox.discard(selected.id);
process.stdout.write(JSON.stringify({{remaining:inbox.list(), keys:Array.from(values.keys()).sort()}}));
"""
output = run_node(script)
assert [item["kind"] for item in output["remaining"]] == ["pull-comment"]
assert "stackchain.issue-comment.v1:stackchain/api#17" not in output["keys"]
assert "stackchain.issue-comment.v1:stackchain/api#17:operation" not in output["keys"]
assert "stackchain.pull-comment.v1:stackchain/web#9" in output["keys"]
def test_draft_inbox_recovers_issue_edit_and_review_content_without_exposing_empty_records():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-content.v1:stackchain/api#18', JSON.stringify({{title:'Clarify acceptance',body:'Describe mobile flow',expectedUpdatedAt:'2026-08-07T10:00:00Z'}})],
['stackchain.review-draft.v1:stackchain/web#12@abc123', JSON.stringify({{notes:{{}},comments:[],summary:'',decision:'approve'}})],
['stackchain.pull-comment.v1:stackchain/web#10', ' '],
]);
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const drafts = createDraftInbox({{storage,now:()=>42}}).list();
process.stdout.write(JSON.stringify(drafts));
"""
output = run_node(script)
assert len(output) == 1
assert output[0]["kind"] == "issue-edit"
assert output[0]["preview"] == "Clarify acceptance — Describe mobile flow"
assert output[0]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 18}
@pytest.mark.anyio
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
html = await dashboard()
assert '<script src="static/drafts.js"></script>' in html
assert 'data-work-filter="draft"' in html
assert 'data-work-count="draft"' in html
assert 'class="draft-resume"' in html
assert 'class="draft-discard"' in html
assert 'Discard this unfinished draft?' in html
assert '.draft-actions button { min-height:44px;' in html
assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
+110 -1
View File
@@ -13,6 +13,7 @@ def reset_available_issue_snapshot():
main._available_issue_snapshot_task = None
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
main._available_issue_snapshot_retry_at = None
yield
task = main._available_issue_snapshot_task
if task is not None and not task.done():
@@ -20,6 +21,7 @@ def reset_available_issue_snapshot():
main._available_issue_snapshot_task = None
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
main._available_issue_snapshot_retry_at = None
@pytest.mark.anyio
@@ -136,6 +138,53 @@ async def test_available_issue_page_ranks_all_upstream_pages_before_logical_pagi
}
@pytest.mark.anyio
async def test_available_issue_snapshot_loads_known_remaining_pages_concurrently():
active = 0
peak_active = 0
remaining_started = asyncio.Event()
release = asyncio.Event()
async def upstream(request):
nonlocal active, peak_active
page = int(request.url.params["page"])
if page == 1:
return httpx.Response(
200, headers={"X-Total-Count": "200"},
json=[{
"id": number, "number": number, "title": f"Issue {number}",
"state": "open", "assignees": [], "pull_request": None,
"labels": [], "repository": {"full_name": "stackchain/api"},
} for number in range(1, 51)],
)
active += 1
peak_active = max(peak_active, active)
if peak_active == 3:
remaining_started.set()
await release.wait()
active -= 1
start = (page - 1) * 50 + 1
return httpx.Response(200, headers={"X-Total-Count": "200"}, json=[{
"id": number, "number": number, "title": f"Issue {number}",
"state": "open", "assignees": [], "pull_request": None,
"labels": [], "repository": {"full_name": "stackchain/api"},
} for number in range(start, start + 50)])
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
task = asyncio.create_task(gitea_proxy.available_issue_snapshot())
try:
await asyncio.wait_for(remaining_started.wait(), timeout=1)
assert peak_active == 3
release.set()
result = await asyncio.wait_for(task, timeout=1)
finally:
release.set()
await gitea_proxy.stop_client()
assert len(result) == 200
assert peak_active == 3
@pytest.mark.anyio
async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkeypatch):
calls = []
@@ -204,7 +253,67 @@ async def test_available_issue_endpoint_retains_last_snapshot_on_refresh_failure
assert response.status_code == 200
assert response.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True,
"has_more": False, "stale": True, "revalidating": True,
}
@pytest.mark.anyio
async def test_available_issue_endpoint_serves_expired_snapshot_while_one_refresh_runs(monkeypatch):
calls = 0
started = asyncio.Event()
release = asyncio.Event()
async def refresh():
nonlocal calls
calls += 1
started.set()
await release.wait()
return [{"number": 8}]
main._available_issue_snapshot_value = [{"number": 7}]
main._available_issue_snapshot_created_at = 0.0
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", refresh)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first, second = await asyncio.gather(
client.get("/api/v1/available-issues?page=1"),
client.get("/api/v1/available-issues?page=1"),
)
await asyncio.wait_for(started.wait(), timeout=1)
assert calls == 1
assert first.json() == second.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True, "revalidating": True,
}
release.set()
await asyncio.wait_for(main._available_issue_snapshot_task, timeout=1)
assert main._available_issue_snapshot_value == [{"number": 8}]
@pytest.mark.anyio
async def test_available_issue_endpoint_backs_off_after_background_refresh_failure(monkeypatch):
calls = 0
async def unavailable():
nonlocal calls
calls += 1
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:
first = await client.get("/api/v1/available-issues?page=1")
await asyncio.gather(main._available_issue_snapshot_task, return_exceptions=True)
second = await client.get("/api/v1/available-issues?page=1")
assert calls == 1
assert first.json()["revalidating"] is True
assert second.json() == {
"items": [{"number": 7}], "page": 1, "total": 1,
"has_more": False, "stale": True, "refresh_failed": True,
}
+108
View File
@@ -912,6 +912,44 @@ async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkey
}
@pytest.mark.anyio
async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no_store(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return repository == "stackchain/api"
async def conversation(repository, number, page, limit):
calls.append(("conversation", repository, number, page, limit))
return {"comments": [{"id": 21}], "page": 2, "older_page": 1, "total": 21}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
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/comments?page=2&limit=20"
)
missing = await client.get(
"/api/v1/repos/private/secret/issues/7/comments?page=2&limit=20"
)
invalid = await client.get(
"/api/v1/repos/stackchain/api/issues/7/comments?page=999&limit=999"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["older_page"] == 1
assert missing.status_code == 404
assert invalid.status_code == 422
assert calls == [
("assigned", "stackchain/api", 7),
("conversation", "stackchain/api", 7, 2, 20),
("assigned", "private/secret", 7),
]
@pytest.mark.anyio
async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch):
cancelled = asyncio.Event()
@@ -1157,9 +1195,79 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
}
],
"conversation": {
"comments": [
{
"id": 81,
"author": "sam",
"body": "Latest update",
"created_at": "2026-08-07T10:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
}
],
"page": 1,
"older_page": None,
"total": 1,
},
}
@pytest.mark.anyio
async def test_gitea_conversation_page_opens_newest_page_and_reports_older_history():
requests = []
async def handler(request):
requests.append((request.url.path, request.url.query.decode()))
page = request.url.params.get("page")
comments = {
"1": [{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
for value in range(1, 21)],
"3": [{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
for value in range(41, 48)],
}[page]
return httpx.Response(200, json=comments, headers={"X-Total-Count": "47"})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_conversation_page("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == [
("/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
("/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=3"),
]
assert [comment["id"] for comment in result["comments"]] == list(range(41, 48))
assert result["page"] == 3
assert result["older_page"] == 2
assert result["total"] == 47
@pytest.mark.anyio
async def test_gitea_conversation_page_locally_bounds_unpaginated_gitea_comments():
requests = []
all_comments = [
{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
for value in range(1, 48)
]
async def handler(request):
requests.append(request.url.query.decode())
return httpx.Response(200, json=all_comments, headers={"X-Total-Count": "47"})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_conversation_page("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == ["limit=20&page=1"]
assert [comment["id"] for comment in result["comments"]] == list(range(41, 48))
assert result["page"] == 3
assert result["older_page"] == 2
assert result["total"] == 47
@pytest.mark.anyio
async def test_update_assigned_issue_labels_validates_and_returns_confirmed_labels(monkeypatch):
calls = []
+339 -1
View File
@@ -12,6 +12,7 @@ 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"
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
@@ -100,6 +101,74 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
def test_work_route_controller_resolves_cold_routes_without_erasing_the_fragment():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const location = {{hash:'#/my-work/issue/stackchain/api/87'}};
const calls = [];
const controller = routes.createController({{
location,
history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
eventTarget: {{addEventListener() {{}}}},
resolve: async route => {{
calls.push(['resolve', route.kind, route.repository, route.number]);
return {{kind:'issue', repository:'stackchain/api', number:87, title:'Older assigned issue'}};
}},
onResolving: route => calls.push(['resolving', route.number]),
onOpen: item => calls.push(['open', item.number, item.title]),
onClose() {{}},
onInvalid: () => calls.push(['invalid']),
onError: () => calls.push(['error']),
}});
(async () => {{
controller.start();
controller.setItems([]);
await new Promise(resolve => setTimeout(resolve, 0));
process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"calls": [
["resolving", 87],
["resolve", "issue", "stackchain/api", 87],
["open", 87, "Older assigned issue"],
],
"hash": "#/my-work/issue/stackchain/api/87",
}
def test_work_route_controller_dismisses_only_confirmed_unavailable_routes():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const location = {{hash:'#/my-work/update/913'}};
const calls = [];
const unavailable = new Error('gone'); unavailable.unavailable = true;
const controller = routes.createController({{
location,
history: {{pushState() {{}}, replaceState() {{}}, back() {{}}}},
eventTarget: {{addEventListener() {{}}}},
resolve: async () => {{ throw unavailable; }},
onResolving() {{}}, onOpen() {{}}, onClose() {{}},
onInvalid: () => calls.push('invalid'),
onError: () => calls.push('error'),
}});
(async () => {{
controller.start(); controller.setItems([]);
await new Promise(resolve => setTimeout(resolve, 0));
process.stdout.write(JSON.stringify(calls));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == ["invalid"]
def test_work_route_share_prefers_native_share_and_falls_back_to_clipboard():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
@@ -135,6 +204,9 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share
assert '<script src="static/work-route.js"></script>' in html
assert 'const workRoute = createWorkRoute.createController({' in html
assert 'workRoute.setItems(lastMyWork);' in html
assert "api('api/v1/work-route?' + params.toString())" in html
assert "Loading shared work item…" in html
assert 'id="retry-work-route"' in html
assert 'href="' + "' + escAttr(createWorkRoute.serialize(" in html
assert '.read-update { min-height:44px; width:100%; display:flex;' in html
assert 'workRoute.close();' in html
@@ -463,6 +535,71 @@ async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor
assert 'buildMyWork.replaceIssueMilestone(' in html
@pytest.mark.anyio
async def test_mobile_issue_sheet_puts_reading_before_collapsed_planning_controls():
html = await dashboard()
body = html.index('id="issue-sheet-body"')
conversation = html.index('<h2>Full conversation</h2>', body)
planning = html.index('id="issue-planning"', conversation)
labels = html.index('id="issue-label-editor"', planning)
milestone = html.index('id="issue-milestone"', planning)
assert '<summary>Plan &amp; edit</summary>' in html[planning:labels]
assert '<details class="issue-planning" id="issue-planning">' in html
assert body < conversation < planning < labels < milestone
def test_issue_planning_metadata_is_lazy_cached_and_retryable_after_failure():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const calls = [];
let fail = true;
const planning = createIssueSheet.createPlanningLoader({{
loadLabels: async item => {{ calls.push('labels:' + item.number); return [{{id:1,name:'P0'}}]; }},
loadMilestones: async item => {{
calls.push('milestones:' + item.number);
if (fail) throw new Error('offline');
return [{{id:9,title:'RC'}}];
}},
}});
const item = {{repository:'stackchain/api', number:17}};
(async () => {{
const before = calls.slice();
let failed = false;
try {{ await planning.open(item); }} catch (_error) {{ failed = true; }}
fail = false;
const first = await planning.open(item);
const second = await planning.open(item);
process.stdout.write(JSON.stringify({{before, failed, calls, same:first === second, first}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"before": [],
"failed": True,
"calls": ["labels:17", "milestones:17", "labels:17", "milestones:17"],
"same": True,
"first": {"labels": [{"id": 1, "name": "P0"}], "milestones": [{"id": 9, "title": "RC"}]},
}
@pytest.mark.anyio
async def test_opening_issue_defers_planning_requests_until_disclosure_expands():
html = await dashboard()
open_handler = html[html.index('async function openIssueSheet'):html.index('function closeIssueSheet')]
assert 'loadIssueLabelEditor(' not in open_handler
assert 'loadIssueMilestoneEditor(' not in open_handler
assert "if (qs('#issue-planning').open) loadIssuePlanning();" in open_handler
assert "qs('#issue-planning').addEventListener('toggle'" in html
assert 'planningLoader.open(selectedIssue)' in html
assert 'id="retry-issue-planning"' in html
def test_my_work_reviews_filter_and_summary_are_actionable():
items = [
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
@@ -848,6 +985,40 @@ controller.load().then(() => controller.loadMore()).then(() =>
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
def test_find_work_keeps_retained_cards_and_announces_refresh_freshness():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
const statuses = [];
const states = [];
const responses = [
{{items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:1,has_more:false,
stale:true,revalidating:true}},
{{items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:1,has_more:false,
stale:true,refresh_failed:true}},
];
const controller = createFindWork({{
fetchJson: () => Promise.resolve(responses.shift()),
onItems: items => states.push(items),
onPagination: () => {{}},
onStatus: status => statuses.push(status),
}});
controller.load().then(() => controller.load()).then(() =>
process.stdout.write(JSON.stringify({{statuses,states,items:controller.items()}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["statuses"] == [
"Showing saved available work while the catalog refreshes…",
"Showing saved available work. Catalog refresh failed; retrying shortly.",
]
assert [item["number"] for item in output["items"]] == [1]
assert len(output["states"]) == 2
def test_find_work_preview_stays_with_issue_across_pagination_and_clears_when_claimed():
script = f"""
const createFindWork = require({json.dumps(str(PICK_WORK))});
@@ -928,6 +1099,9 @@ async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe
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
assert 'const retainedItems = findWorkController.items();' in html
assert 'if (retainedItems.length) renderAvailableIssues(retainedItems);' in html
assert 'Refreshing available issues…' in html
@pytest.mark.anyio
@@ -1498,6 +1672,100 @@ reader.open(item, [item]).then(() => {{
]
def test_conversation_pager_prepends_older_pages_deduplicates_and_appends_once():
script = f"""
const createConversationPager = require({json.dumps(str(CONVERSATION))});
let calls = 0;
const pager = createConversationPager({{
loadPage: async page => {{ calls += 1; return {{
comments:[{{id:20,body:'duplicate'}},{{id:1,body:'oldest'}}],
page, older_page:null, total:3
}}; }}
}});
pager.reset({{comments:[{{id:20,body:'middle'}},{{id:21,body:'newest'}}],page:2,older_page:1,total:3}});
const first = pager.loadOlder();
const duplicate = pager.loadOlder();
Promise.all([first, duplicate]).then(() => {{
pager.append({{id:22,body:'posted'}});
pager.append({{id:22,body:'posted'}});
process.stdout.write(JSON.stringify({{calls,same:first===duplicate,state:pager.snapshot()}}));
}});
"""
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 [comment["id"] for comment in output["state"]["comments"]] == [1, 20, 21, 22]
assert output["state"]["older_page"] is None
assert output["state"]["total"] == 4
def test_conversation_pager_keeps_loaded_messages_when_older_page_fails():
script = f"""
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const pager = createConversationPager({{loadPage: async () => {{ throw new Error('offline'); }}}});
pager.reset({{comments:[{{id:21,body:'newest'}}],page:2,older_page:1,total:21}});
pager.loadOlder().catch(error => process.stdout.write(JSON.stringify({{
error:error.message, state:pager.snapshot()
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["error"] == "offline"
assert output["state"]["comments"] == [{"id": 21, "body": "newest"}]
assert output["state"]["older_page"] == 1
def test_issue_sheet_conversation_loads_older_history_through_assigned_boundary():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const calls = [];
const controller = createIssueSheet({{
createConversationPager,
fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
}});
const pager = controller.conversation(
{{repository:'stackchain/api',number:7}},
{{comments:[{{id:21}}],page:2,older_page:1,total:21}}
);
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
"""
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/issues/7/comments?page=1&limit=20"]
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]
def test_pull_sheet_conversation_loads_older_history_through_assigned_boundary():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const createConversationPager = require({json.dumps(str(CONVERSATION))});
const calls = [];
const controller = createPullSheet({{
createConversationPager,
fetchJson: async url => {{ calls.push(url); return {{comments:[{{id:1}}],page:1,older_page:null,total:21}}; }}
}});
const pager = controller.conversation(
{{repository:'stackchain/api',number:7}},
{{comments:[{{id:21}}],page:2,older_page:1,total:21}}
);
pager.loadOlder().then(state => process.stdout.write(JSON.stringify({{calls,state}})));
"""
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/pulls/7/comments?page=1&limit=20"]
assert [comment["id"] for comment in output["state"]["comments"]] == [1, 21]
def test_issue_sheet_loads_encoded_assigned_issue_detail_path():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -1755,6 +2023,63 @@ process.stdout.write(JSON.stringify({{
assert output["next"] == "frontend/app.js"
def test_pull_sheet_lazy_review_is_single_flight_cached_by_head_and_retryable():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
let rejectFirst;
const fetchJson = url => {{
calls.push(url);
if (calls.length === 1) return new Promise((_resolve, reject) => {{ rejectFirst = reject; }});
return Promise.resolve({{head_sha:'abc123', files:[]}});
}};
const sheet = createPullSheet({{fetchJson, storage:null}});
const item = {{repository:'stackchain/api', number:7}};
const first = sheet.loadReview(item, 'abc123');
const concurrent = sheet.loadReview(item, 'abc123');
rejectFirst(new Error('diff unavailable'));
Promise.allSettled([first, concurrent]).then(async failed => {{
const retried = await sheet.loadReview(item, 'abc123');
const cached = await sheet.loadReview(item, 'abc123');
process.stdout.write(JSON.stringify({{
same:first === concurrent,
failed:failed.map(result => result.status),
retried,
cached,
calls,
}}));
}});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert output["same"] is True
assert output["failed"] == ["rejected", "rejected"]
assert output["retried"]["head_sha"] == "abc123"
assert output["cached"] == output["retried"]
assert output["calls"] == [
"api/v1/repos/stackchain/api/pulls/7/review-data",
"api/v1/repos/stackchain/api/pulls/7/review-data",
]
@pytest.mark.anyio
async def test_mobile_pull_sheet_puts_reading_before_collapsed_review_controls():
html = await dashboard()
body = html.index('id="pull-sheet-body"')
conversation = html.index('<h2>Full conversation</h2>', body)
composer = html.index('id="pull-comment-title"', conversation)
review = html.index('id="pull-review"', composer)
files = html.index('id="pull-files"', review)
assert '<summary><h2>Review &amp; merge</h2></summary>' in html
assert '<details class="pull-review" id="pull-review">' in html
assert 'id="pull-review-retry"' in html
assert 'id="pull-review-status"' in html
assert body < conversation < composer < review < files
def test_pull_sheet_renders_mobile_diff_fallbacks_and_review_controls():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -1789,6 +2114,12 @@ async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
assert 'id="pull-files"' in html and 'id="pull-comments"' in html
assert 'id="pull-review-progress"' in html and 'aria-live="polite"' in html
assert 'id="next-unreviewed-pull-file"' in html
assert 'id="load-older-pull-comments"' in html
assert 'id="pull-conversation-status"' in html and 'aria-live="assertive"' in html
assert '<script src="static/conversation.js"></script>' in html
assert "pullController.conversation(item, detail.conversation)" in html
assert "pullConversation.loadOlder()" in html
assert "pullConversation.append(comment)" 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
@@ -1814,6 +2145,12 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut
assert 'id="issue-sheet-body"' in html
assert 'id="issue-labels"' in html and 'id="issue-assignees"' in html
assert 'id="issue-comments"' in html
assert 'id="load-older-issue-comments"' in html
assert 'id="issue-conversation-status"' in html and 'aria-live="assertive"' in html
assert "issueController.conversation(item, detail.conversation)" in html
assert "issueConversation.loadOlder()" in html
assert "issueConversation.append(comment)" in html
assert '.conversation-more { min-height:44px;' in html
assert 'id="issue-comment"' in html and 'maxlength="10000"' in html
assert 'id="send-issue-comment"' in html
assert 'id="close-issue"' in html
@@ -2072,7 +2409,8 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
assert 'data-work-count="pull"' in html
assert 'data-work-count="review"' in html
assert 'data-work-count="update"' in html
assert "['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)" in html
assert "['all', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)" in html
assert 'data-work-count="draft"' in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
+104 -2
View File
@@ -54,7 +54,77 @@ async def test_assigned_pull_detail_rejects_unassigned_pull(monkeypatch):
@pytest.mark.anyio
async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
async def test_gitea_assigned_pull_detail_loads_reading_without_review_resources():
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,
"title": "Read this first",
"body": "The discussion should not wait for the diff.",
"state": "open",
"mergeable": True,
"head": {"sha": "abc123"},
"user": {"login": "alex"},
})
if request.url.path.endswith("/issues/7/comments"):
return httpx.Response(200, json=[{
"id": 9, "body": "Question", "user": {"login": "sam"},
"created_at": "2026-08-07T18:00:00Z",
}], headers={"X-Total-Count": "1"})
raise AssertionError(f"review resource requested during read load: {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
detail = await gitea_proxy.pull_completion_detail("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/issues/7/comments"),
]
assert detail["head_sha"] == "abc123"
assert detail["conversation"]["comments"][0]["body"] == "Question"
assert "files" not in detail
assert "ci_state" not in detail
@pytest.mark.anyio
async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def review(repository, number):
calls.append(("review", repository, number))
return {
"repository": repository, "number": number, "head_sha": "abc123",
"state": "open", "draft": False, "mergeable": True, "merged": False,
"ci_state": "success", "files": [{"filename": "src/api.py"}],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", review, 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/review-data")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["files"] == [{"filename": "src/api.py"}]
assert calls == [
("assigned", "stackchain/api", 7),
("review", "stackchain/api", 7),
]
@pytest.mark.anyio
async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
async def handler(request):
path = request.url.path
if path.endswith("/pulls/7"):
@@ -86,7 +156,7 @@ async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
detail = await gitea_proxy.pull_completion_detail("stackchain/api", 7)
detail = await gitea_proxy.pull_completion_review("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
@@ -94,6 +164,38 @@ async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
assert "+new" in detail["files"][0]["diff_lines"]
assert detail["files"][1]["diff_binary"] is True
assert detail["files"][1]["diff_available"] is False
assert "conversation" not in detail
@pytest.mark.anyio
async def test_assigned_pull_conversation_endpoint_reuses_issue_thread_with_pull_authorization(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def conversation(repository, number, page, limit):
calls.append(("conversation", repository, number, page, limit))
return {"comments": [{"id": 41}], "page": 3, "older_page": 2, "total": 47}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
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/comments?page=3&limit=20"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"comments": [{"id": 41}], "page": 3, "older_page": 2, "total": 47
}
assert calls == [
("assigned", "stackchain/api", 7),
("conversation", "stackchain/api", 7, 3, 20),
]
@pytest.mark.anyio
+100
View File
@@ -0,0 +1,100 @@
import httpx
import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_work_route_endpoint_resolves_one_authorized_item(monkeypatch):
requested = []
async def resolve(kind, repository, number, notification_id):
requested.append((kind, repository, number, notification_id))
return {
"kind": "review",
"repository": "stackchain/dashboard",
"number": 87,
"title": "Review the mobile resolver",
"is_review": True,
"url": "https://forge.example/stackchain/dashboard/pulls/87",
}
monkeypatch.setattr(main.gitea_proxy, "resolve_work_route", resolve, 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/work-route?kind=review&repository=stackchain/dashboard&number=87"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("review", "stackchain/dashboard", 87, None)]
assert response.json() == {
"kind": "review",
"repository": "stackchain/dashboard",
"number": 87,
"title": "Review the mobile resolver",
"is_review": True,
"url": "https://forge.example/stackchain/dashboard/pulls/87",
}
@pytest.mark.anyio
async def test_resolve_work_route_returns_only_a_requested_review():
requests = []
async def upstream(request):
requests.append(request.url.path)
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 700,
"number": 87,
"title": "Review the resolver",
"state": "open",
"html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/pulls/87",
"repository": {"full_name": "stackchain/dashboard"},
"requested_reviewers": [{"login": "timmy"}],
"assignees": [],
"user": {"login": "alex"},
})
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.resolve_work_route(
"review", "stackchain/dashboard", 87, None
)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 2
assert result == {
"kind": "review",
"repository": "stackchain/dashboard",
"number": 87,
"title": "Review the resolver",
"state": "open",
"url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/pulls/87",
"is_review": True,
"work_reasons": ["review_requested"],
}
@pytest.mark.anyio
async def test_resolve_work_route_rejects_a_notification_that_is_already_read():
async def upstream(request):
if request.url.path.endswith("/notifications/threads/913"):
return httpx.Response(200, json={
"id": 913,
"unread": False,
"repository": {"full_name": "stackchain/dashboard"},
"subject": {"title": "Already handled", "type": "Issue"},
})
return httpx.Response(404)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
with pytest.raises(gitea_proxy.WorkRouteUnavailableError):
await gitea_proxy.resolve_work_route("update", None, None, 913)
finally:
await gitea_proxy.stop_client()