Compare commits

...
Author SHA1 Message Date
timmy 2072de337e feat: add mobile My Work sessions (#193)
CI / lint (pull_request) Successful in 14s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 11:54:31 +00:00
rockachopa ecd4b55a26 Merge pull request 'Schedule assigned issues from mobile My Work' (#192) from timmy/191-mobile-issue-due-dates into main
CI / lint (push) Successful in 16s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-07 11:30:32 +00:00
timmy c639b09aaf feat: schedule assigned issues from mobile (#191)
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 11:29:35 +00:00
timmy 5406ee2297 Merge pull request 'Edit assigned issue content safely from mobile' (#190) from timmy/189-edit-assigned-issue-content into main
CI / lint (push) Successful in 14s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-07 11:08:46 +00:00
timmy 937df5d20b feat: edit assigned issue content from mobile (#189)
CI / lint (pull_request) Successful in 15s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 11:07:56 +00:00
rockachopa c0eabbf865 Merge pull request 'Review assigned pull diffs before mobile merge' (#188) from timmy/187-review-assigned-pull-diffs into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-07 10:24:08 +00:00
timmy 2d8a659ebe feat: review assigned pull diffs before merge (#187)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 10:23:15 +00:00
rockachopa 3ff173ca89 Merge pull request 'Make mobile authored actions idempotent across retries' (#186) from timmy/185-idempotent-authored-actions into main
CI / lint (push) Successful in 13s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-07 09:56:14 +00:00
timmy 0bc6e01eb5 feat: make authored actions idempotent (#185)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s
2026-08-07 09:55:21 +00:00
timmy 4da3b198d0 Release mobile issue assignments from My Work (#184)
CI / lint (push) Successful in 14s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Closes #183
2026-08-07 09:25:06 +00:00
15 changed files with 1764 additions and 51 deletions

No files matched your search

+7 -5
View File
@@ -16,14 +16,16 @@ python3 -m pip install -r requirements.txt
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, discover, claim, and release issue assignments,
create issue comments, close assigned issues, inspect/comment on assigned pull
set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue
creation and assignment permission. Issue capture persists a per-draft idempotency key,
so retrying after a timeout or reload replays a confirmed creation instead of posting a
duplicate; callers integrating directly should preserve the `Idempotency-Key` header
with the unchanged payload until a `201` response is confirmed. Closing an assigned issue, native Comment,
creation and assignment permission. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout or reload replays a confirmed result instead
of posting duplicate content. Direct API callers should preserve the `Idempotency-Key`
header with the unchanged route and payload until a `201` response is confirmed. Closing an assigned issue, native Comment,
Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. Native Comment, Approve, and Request changes reviews support
head-scoped draft comments anchored to changed lines; the dashboard validates each
+336 -16
View File
@@ -131,6 +131,13 @@ 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; }
#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; }
.issue-edit-form input, .issue-edit-form textarea { box-sizing:border-box; width:100%; max-width:100%; }
.issue-edit-form textarea { min-height:132px; resize:vertical; }
.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px; }
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
.issue-comment-composer button { min-height:44px; width:100%; }
@@ -139,6 +146,10 @@ textarea { resize: vertical; min-height: 120px; }
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
.issue-label-option input { width:20px; height:20px; flex:0 0 auto; }
.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; }
.issue-due-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-due-editor input { box-sizing:border-box; width:100%; max-width:100%; }
.issue-due-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.issue-due-editor input, .issue-due-editor button { min-height:44px; }
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@@ -146,6 +157,11 @@ textarea { resize: vertical; min-height: 120px; }
.new-issue { min-height:44px; }
.find-work-action { min-height:44px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.start-work-session { min-height:44px; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.work-session-nav[hidden] { display:none; }
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
.work-session-nav button { min-height:44px; width:100%; }
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.find-work-sheet.open { display:flex; }
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
@@ -178,6 +194,17 @@ 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; }
.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; }
.pull-diff { overflow-x:auto; margin:8px 0; padding:10px; background:#07111f; border-radius:8px; font-size:12px; }
.pull-diff-line { display:block; width:max-content; min-width:100%; }
.pull-diff-line.added { color:#86efac; background:#123422; }
.pull-diff-line.removed { color:#fca5a5; background:#3b161b; }
.pull-diff-line.hunk, .pull-diff-note { color:#93c5fd; }
.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-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; }
@@ -219,6 +246,7 @@ textarea { resize: vertical; min-height: 120px; }
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
</div>
<div class="my-work-actions">
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
<button class="find-work-action" id="find-work" type="button">Find work</button>
<button class="new-issue" id="new-issue" type="button">New issue</button>
</div>
@@ -335,7 +363,7 @@ textarea { resize: vertical; min-height: 120px; }
<button id="close-issue-sheet" type="button">Close sheet</button>
</div>
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Retry loading issue</button>
<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>
@@ -343,7 +371,31 @@ textarea { resize: vertical; min-height: 120px; }
<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>
<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>
<div id="issue-comments"></div>
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
@@ -357,6 +409,11 @@ textarea { resize: vertical; min-height: 120px; }
<button id="close-issue" type="button">Close issue</button>
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
</div>
@@ -436,6 +493,11 @@ textarea { resize: vertical; min-height: 120px; }
<button id="mark-update-read-next" type="button">Mark read &amp; next</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
</div>
@@ -449,7 +511,9 @@ textarea { resize: vertical; min-height: 120px; }
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Checking merge status</span></div>
<p class="pull-sheet-content" id="pull-sheet-body"></p>
<h2>Changed files</h2><div id="pull-files"></div>
<h2>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>
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2>
@@ -461,6 +525,11 @@ textarea { resize: vertical; min-height: 120px; }
<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>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
</div>
@@ -516,6 +585,11 @@ textarea { resize: vertical; min-height: 120px; }
</section>
<h2>Review history</h2>
<div id="review-history" class="muted"></div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
</div>
@@ -576,10 +650,12 @@ textarea { resize: vertical; min-height: 120px; }
let selectedUpdate = null;
let updateTrigger = null;
let selectedIssue = null;
let selectedIssueDetail = null;
let issueTrigger = null;
let selectedPull = null;
let pullTrigger = null;
let selectedPullDetail = null;
let pullReviewState = null;
let creatingIssue = false;
let findingWork = false;
let availablePagination = { page: 1, total: 0, has_more: false };
@@ -598,7 +674,7 @@ textarea { resize: vertical; min-height: 120px; }
if (!response.ok) throw new Error(payload.error || 'Review request failed.');
return payload;
}
const reviewController = createReviewController({ fetchJson: fetchReviewJson });
const reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
@@ -670,10 +746,13 @@ textarea { resize: vertical; min-height: 120px; }
return payload;
}
async function postNotificationReply(notificationId, body) {
async function postNotificationReply(notificationId, body, operationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': operationId,
},
body: JSON.stringify({ body }),
});
const payload = await response.json().catch(() => ({}));
@@ -784,6 +863,59 @@ textarea { resize: vertical; min-height: 120px; }
onClose: () => closeUpdateSheet(false),
});
function closeOpenWorkSheets() {
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
qs(selector).classList.remove('open')
);
selectedIssue = null;
selectedIssueDetail = null;
selectedPull = null;
selectedPullDetail = null;
selectedReview = null;
selectedUpdate = null;
}
function openWorkSessionItem(item) {
closeOpenWorkSheets();
if (selectedWorkFilter === 'update' && item.has_update && Number.isInteger(item.notification_id)) {
updateTrigger = null;
notificationReader.open(item, lastMyWork);
} else if (item.is_review) {
openReviewSheet(item, null);
} else if (item.kind === 'issue') {
openIssueSheet(item, null);
} else if (item.kind === 'pull') {
openPullSheet(item, null);
} else if (item.has_update && Number.isInteger(item.notification_id)) {
updateTrigger = null;
notificationReader.open(item, lastMyWork);
}
}
const workSession = createWorkSession({
getItems: () => lastMyWork,
getFilter: () => selectedWorkFilter,
onOpen: openWorkSessionItem,
onProgress: state => {
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
document.querySelectorAll('[data-work-session-progress]').forEach(element => {
element.textContent = 'Item ' + state.index + ' of ' + state.total;
});
document.querySelectorAll('[data-work-session-previous]').forEach(button => {
button.disabled = !state.can_previous;
});
document.querySelectorAll('[data-work-session-next]').forEach(button => {
button.textContent = state.can_next ? 'Next work item' : 'Finish session';
});
},
onFinish: () => {
closeOpenWorkSheets();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
qs('#my-work-action-status').textContent = 'Work session complete.';
qs('#start-work-session').focus();
},
});
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
@@ -861,6 +993,7 @@ textarea { resize: vertical; min-height: 120px; }
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
updateWorkPaginationControls();
renderMyWork();
if (workSession.active()) workSession.reconcile();
}
function activeWorkStreams() {
@@ -895,6 +1028,7 @@ textarea { resize: vertical; min-height: 120px; }
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
(item.due_label ? ' <span class="pill due-badge">' + escapeHtml(item.due_label) + '</span>' : '') +
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
@@ -1034,6 +1168,7 @@ textarea { resize: vertical; min-height: 120px; }
async function openIssueSheet(item, trigger) {
if (!item) return;
selectedIssue = item;
selectedIssueDetail = null;
issueTrigger = trigger;
qs('#issue-sheet').classList.add('open');
qs('#issue-sheet-key').textContent = item.key || '';
@@ -1048,14 +1183,23 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-label-list').textContent = '';
qs('#issue-label-status').textContent = 'Loading labels…';
qs('#save-issue-labels').disabled = true;
qs('#issue-due-date').value = '';
qs('#issue-due-date').disabled = true;
qs('#save-issue-due-date').disabled = true;
qs('#clear-issue-due-date').disabled = true;
qs('#issue-due-status').textContent = 'Loading due date…';
qs('#retry-issue-load').hidden = true;
qs('#open-issue-gitea').href = item.url || '#';
qs('#send-issue-comment').disabled = false;
qs('#edit-issue-content').disabled = true;
qs('#issue-edit-form').hidden = true;
qs('#issue-edit-status').textContent = '';
qs('#close-issue').disabled = false;
qs('#close-issue-sheet').focus();
try {
const detail = await issueController.load(item);
if (selectedIssue !== item) return;
selectedIssueDetail = detail;
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 =>
@@ -1068,6 +1212,14 @@ textarea { resize: vertical; min-height: 120px; }
detail.comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
qs('#open-issue-gitea').href = detail.url || item.url || '#';
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
qs('#edit-issue-content').disabled = false;
const dueDraft = issueController.loadDueDateDraft(item);
qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10);
qs('#issue-due-date').disabled = false;
qs('#save-issue-due-date').disabled = false;
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.';
} catch (error) {
if (selectedIssue !== item) return;
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
@@ -1079,20 +1231,71 @@ textarea { resize: vertical; min-height: 120px; }
function closeIssueSheet() {
qs('#issue-sheet').classList.remove('open');
selectedIssue = null;
selectedIssueDetail = null;
if (issueTrigger?.isConnected) issueTrigger.focus();
}
function renderPullReview(detail, focusFilename = null) {
pullReviewState = pullController.reviewState(selectedPull, detail);
qs('#pull-review-progress').textContent = pullReviewState.total ?
pullReviewState.reviewed.length + ' of ' + pullReviewState.total + ' files reviewed' : 'No changed files to review';
qs('#next-unreviewed-pull-file').disabled = pullReviewState.complete;
const eligibility = createPullSheet.mergeEligibility(detail, pullReviewState);
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, pullReviewState.reviewed.includes(file.filename), escapeHtml)
).join('') : '<div class="muted">No changed files reported.</div>';
qs('#pull-files').querySelectorAll('.pull-file-toggle').forEach(button => {
button.addEventListener('click', () => {
const panel = document.getElementById(button.getAttribute('aria-controls'));
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
if (panel) panel.hidden = expanded;
});
});
qs('#pull-files').querySelectorAll('.pull-review-file').forEach(button => {
button.addEventListener('click', () => {
const filename = button.dataset.pullReviewFile;
pullReviewState = pullController.toggleReviewed(selectedPull, detail, filename);
renderPullReview(detail, filename);
});
});
if (focusFilename) {
Array.from(qs('#pull-files').querySelectorAll('.pull-review-file'))
.find(button => button.dataset.pullReviewFile === focusFilename)?.focus();
}
}
function focusNextUnreviewedPullFile() {
if (!selectedPullDetail || !pullReviewState) return;
const filename = pullController.nextUnreviewed(selectedPullDetail, pullReviewState);
const article = Array.from(qs('#pull-files').querySelectorAll('.pull-file'))
.find(file => file.dataset.pullFilename === filename);
const toggle = article?.querySelector('.pull-file-toggle');
const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
if (toggle && panel) {
toggle.setAttribute('aria-expanded', 'true');
panel.hidden = false;
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
toggle.focus();
}
}
async function openPullSheet(item, trigger) {
if (!item) return;
selectedPull = item;
pullTrigger = trigger;
selectedPullDetail = null;
pullReviewState = null;
qs('#pull-sheet').classList.add('open');
qs('#pull-sheet-key').textContent = item.key || '';
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
qs('#pull-sheet-status').textContent = 'Loading pull request…';
qs('#pull-sheet-body').textContent = '';
qs('#pull-files').textContent = '';
qs('#pull-review-progress').textContent = 'Loading review progress…';
qs('#next-unreviewed-pull-file').disabled = true;
qs('#pull-comments').textContent = '';
qs('#pull-comment').value = pullController.loadDraft(item);
qs('#pull-comment-status').textContent = '';
@@ -1106,16 +1309,10 @@ textarea { resize: vertical; min-height: 120px; }
const detail = await pullController.load(item);
if (selectedPull !== item) return;
selectedPullDetail = detail;
const eligibility = createPullSheet.mergeEligibility(detail);
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
qs('#pull-sheet-body').textContent = detail.body || 'No description provided.';
qs('#pull-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map(file =>
'<div class="pull-file"><strong>' + escapeHtml(file.filename) + '</strong><div class="small">' +
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</div></div>'
).join('') : '<div class="muted">No changed files reported.</div>';
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>';
@@ -1133,6 +1330,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#pull-sheet').classList.remove('open');
selectedPull = null;
selectedPullDetail = null;
pullReviewState = null;
if (pullTrigger?.isConnected) pullTrigger.focus();
}
@@ -1668,6 +1866,74 @@ textarea { resize: vertical; min-height: 120px; }
qs('#retry-issue-load').addEventListener('click', () => {
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
});
qs('#edit-issue-content').addEventListener('click', () => {
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
const draft = issueController.loadEditDraft(selectedIssue) || {
title: selectedIssueDetail.title || '',
body: selectedIssueDetail.body || '',
expectedUpdatedAt: selectedIssueDetail.updated_at,
};
qs('#issue-edit-title').value = draft.title;
qs('#issue-edit-body').value = draft.body;
qs('#issue-edit-form').hidden = false;
qs('#issue-edit-status').textContent = 'Edit the issue, then save.';
qs('#issue-edit-title').focus();
});
['#issue-edit-title', '#issue-edit-body'].forEach(selector =>
qs(selector).addEventListener('input', () => {
if (!selectedIssue || !selectedIssueDetail?.updated_at) return;
issueController.saveEditDraft(selectedIssue, {
title: qs('#issue-edit-title').value,
body: qs('#issue-edit-body').value,
expectedUpdatedAt: issueController.loadEditDraft(selectedIssue)?.expectedUpdatedAt || selectedIssueDetail.updated_at,
});
})
);
qs('#cancel-issue-content').addEventListener('click', () => {
qs('#issue-edit-form').hidden = true;
qs('#edit-issue-content').focus();
});
qs('#issue-edit-form').addEventListener('submit', async event => {
event.preventDefault();
if (!selectedIssue || !selectedIssueDetail?.updated_at || !lastContextSnapshot) return;
const title = qs('#issue-edit-title').value.trim();
const body = qs('#issue-edit-body').value.trim();
if (!title) {
qs('#issue-edit-status').textContent = 'Add a title before saving.';
qs('#issue-edit-title').focus();
return;
}
const editing = selectedIssue;
const savedDraft = issueController.loadEditDraft(editing);
const draft = {
title,
body,
expectedUpdatedAt: savedDraft?.expectedUpdatedAt || selectedIssueDetail.updated_at,
};
const button = qs('#save-issue-content');
button.disabled = true;
qs('#issue-edit-status').textContent = 'Saving issue…';
try {
const confirmed = await issueController.updateContent(editing, draft);
lastContextSnapshot = buildMyWork.replaceIssueContent(
lastContextSnapshot, editing.repository, editing.number, confirmed
);
selectedIssue = { ...editing, ...confirmed, key: editing.key };
selectedIssueDetail = { ...selectedIssueDetail, ...confirmed };
qs('#issue-sheet-title').textContent = confirmed.title;
qs('#issue-sheet-body').textContent = confirmed.body || 'No description provided.';
paintMyWork(lastContextSnapshot);
qs('#issue-edit-form').hidden = true;
qs('#issue-sheet-status').textContent = 'Issue saved.';
qs('#edit-issue-content').focus();
} catch (error) {
qs('#issue-edit-status').textContent = error.message + ' Your draft is safe; reload latest or open in Gitea.';
qs('#retry-issue-load').hidden = false;
qs('#issue-edit-title').focus();
} finally {
button.disabled = false;
}
});
qs('#issue-comment').addEventListener('input', event => {
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
});
@@ -1694,6 +1960,45 @@ textarea { resize: vertical; min-height: 120px; }
button.disabled = false;
}
});
async function saveSelectedIssueDueDate(dueDate) {
if (!selectedIssue || !lastContextSnapshot) return;
const editing = selectedIssue;
const saveButton = qs('#save-issue-due-date');
const clearButton = qs('#clear-issue-due-date');
saveButton.disabled = true;
clearButton.disabled = true;
qs('#issue-due-status').textContent = dueDate ? 'Saving due date…' : 'Clearing due date…';
try {
const confirmed = await issueController.updateDueDate(editing, dueDate);
lastContextSnapshot = buildMyWork.replaceIssueDueDate(
lastContextSnapshot, editing.repository, editing.number, confirmed.due_date
);
selectedIssue = { ...editing, due_date: confirmed.due_date };
selectedIssueDetail = { ...selectedIssueDetail, due_date: confirmed.due_date };
qs('#issue-due-date').value = String(confirmed.due_date || '').slice(0, 10);
paintMyWork(lastContextSnapshot);
qs('#issue-due-status').textContent = confirmed.due_date ?
'Due date saved. My Work reprioritized.' : 'Due date cleared.';
} catch (error) {
qs('#issue-due-status').textContent = error.message + ' Your selection is safe; retry.';
qs('#issue-due-date').focus();
} finally {
saveButton.disabled = false;
clearButton.disabled = !selectedIssueDetail?.due_date;
}
}
qs('#save-issue-due-date').addEventListener('click', () => {
const value = qs('#issue-due-date').value;
if (!value) {
qs('#issue-due-status').textContent = 'Choose a date or use Clear due date.';
qs('#issue-due-date').focus();
return;
}
saveSelectedIssueDueDate(value + 'T23:59:59Z');
});
qs('#clear-issue-due-date').addEventListener('click', () => saveSelectedIssueDueDate(null));
qs('#send-issue-comment').addEventListener('click', async () => {
if (!selectedIssue) return;
const body = qs('#issue-comment').value.trim();
@@ -1730,8 +2035,8 @@ textarea { resize: vertical; min-height: 120px; }
lastContextSnapshot = buildMyWork.removeIssue(
lastContextSnapshot, releasing.repository, releasing.number
);
paintMyWork(lastContextSnapshot);
closeIssueSheet();
paintMyWork(lastContextSnapshot);
qs('#my-work-action-status').textContent = releasing.key + ' released.' +
(confirmed.available ? ' It is available in Find Work.' : ' Other assignees remain.');
} catch (error) {
@@ -1748,10 +2053,10 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-sheet-status').textContent = 'Closing issue…';
try {
await issueController.close(selectedIssue);
closeIssueSheet();
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number)
);
closeIssueSheet();
refreshMyWorkView();
qs('#my-work-action-status').textContent = closing.key + ' closed.';
} catch (error) {
@@ -1764,6 +2069,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#retry-pull-load').addEventListener('click', () => {
if (selectedPull) openPullSheet(selectedPull, pullTrigger);
});
qs('#next-unreviewed-pull-file').addEventListener('click', focusNextUnreviewedPullFile);
qs('#pull-comment').addEventListener('input', event => {
if (selectedPull) pullController.saveDraft(selectedPull, event.target.value);
});
@@ -1799,10 +2105,10 @@ textarea { resize: vertical; min-height: 120px; }
qs('#pull-sheet-status').textContent = 'Merging pull request…';
try {
await pullController.merge(selectedPull, selectedPullDetail.head_sha);
closePullSheet();
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
);
closePullSheet();
refreshMyWorkView();
qs('#my-work-action-status').textContent = merging.key + ' merged.';
} catch (error) {
@@ -1904,7 +2210,8 @@ textarea { resize: vertical; min-height: 120px; }
if (progress) showReviewProgress(progress.snapshot());
qs('#review-submit-status').textContent = 'Review submitted · ' + (result.state || 'complete') + '.';
await load();
button.focus();
if (workSession.active()) workSession.complete();
else button.focus();
} catch (error) {
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry or open in Gitea.';
button.disabled = false;
@@ -1964,6 +2271,19 @@ textarea { resize: vertical; min-height: 120px; }
function load() { return contextPoller.refresh(); }
qs('#refresh').addEventListener('click', load);
qs('#start-work-session').addEventListener('click', () => {
if (!filterMyWork(lastMyWork, selectedWorkFilter).length) {
qs('#my-work-action-status').textContent = 'No visible work to start.';
return;
}
workSession.start();
});
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
button.addEventListener('click', () => workSession.previous())
);
document.querySelectorAll('[data-work-session-next]').forEach(button =>
button.addEventListener('click', () => workSession.next())
);
qs('#load-more-notifications').addEventListener('click', () =>
notificationPager.loadMore(lastNotifications)
);
+76 -3
View File
@@ -1,12 +1,17 @@
function createIssueSheet({ fetchJson, storage }) {
function createIssueSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
let labelRequest = null;
let editRequest = null;
let dueDateRequest = null;
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
const operationKey = item => draftKey(item) + ':operation';
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number;
const dueDateDraftKey = item => 'stackchain.issue-due-date.v1:' + item.repository + '#' + item.number;
return {
load(item) {
@@ -24,9 +29,70 @@ function createIssueSheet({ fetchJson, storage }) {
catch (_error) { return ''; }
},
saveDraft(item, body) {
try { storage?.setItem(draftKey(item), body); }
try {
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
storage?.setItem(draftKey(item), body);
}
catch (_error) { /* The textarea remains the fallback. */ }
},
loadEditDraft(item) {
try {
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
return value && typeof value.title === 'string' && typeof value.body === 'string' &&
typeof value.expectedUpdatedAt === 'string' ? value : null;
} catch (_error) { return null; }
},
saveEditDraft(item, draft) {
try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); }
catch (_error) { /* The edit fields remain the fallback. */ }
},
updateContent(item, draft) {
if (editRequest) return editRequest;
this.saveEditDraft(item, draft);
editRequest = fetchJson(issuePath(item) + '/content', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
title: draft.title,
body: draft.body,
expected_updated_at: draft.expectedUpdatedAt,
}),
}).then(result => {
if (result?.number !== item.number || result?.title !== draft.title || result?.body !== draft.body) {
throw new Error('Issue update was not confirmed.');
}
try { storage?.removeItem(editDraftKey(item)); }
catch (_error) { /* Confirmed upstream content is authoritative. */ }
return result;
}).finally(() => { editRequest = null; });
return editRequest;
},
loadDueDateDraft(item) {
try {
const raw = storage?.getItem(dueDateDraftKey(item));
if (raw === null || raw === undefined) return null;
const value = JSON.parse(raw);
return typeof value === 'string' ? value : null;
} catch (_error) { return null; }
},
updateDueDate(item, dueDate) {
if (dueDateRequest) return dueDateRequest;
try { storage?.setItem(dueDateDraftKey(item), JSON.stringify(dueDate)); }
catch (_error) { /* The date input remains the fallback. */ }
dueDateRequest = fetchJson(issuePath(item) + '/due-date', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ due_date: dueDate }),
}).then(result => {
if (result?.number !== item.number || result?.due_date !== dueDate) {
throw new Error('Issue due date was not confirmed.');
}
try { storage?.removeItem(dueDateDraftKey(item)); }
catch (_error) { /* Confirmed upstream deadline is authoritative. */ }
return result;
}).finally(() => { dueDateRequest = null; });
return dueDateRequest;
},
loadLabelDraft(item) {
try {
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
@@ -82,13 +148,20 @@ function createIssueSheet({ fetchJson, storage }) {
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
let operationId;
try {
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
storage?.setItem(operationKey(item), operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
commentRequest = fetchJson(issuePath(item) + '/comments', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
body: JSON.stringify({ body }),
}).then(result => {
try { storage?.removeItem(draftKey(item)); }
catch (_error) { /* The upstream comment is authoritative. */ }
try { storage?.removeItem(operationKey(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
return result;
}).finally(() => { commentRequest = null; });
return commentRequest;
+140 -6
View File
@@ -1,4 +1,20 @@
function buildMyWork(data) {
function issueDueState(dueDate, now) {
if (!dueDate) return null;
const due = new Date(dueDate);
if (Number.isNaN(due.getTime())) return null;
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
String(value.getDate()).padStart(2, '0');
const dueDay = day(due);
const today = day(now);
if (dueDay < today) return { label: 'Overdue', priority: 2 };
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
return {
label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
priority: 4,
};
}
function buildMyWork(data, now = new Date()) {
const login = data.user?.login || '';
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
@@ -11,15 +27,19 @@ function buildMyWork(data) {
);
const assigned = (item.assignees || []).includes(login);
const isReview = (item.work_reasons || []).includes('review_requested');
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
return {
...item,
key: (item.repository || 'unknown') + '#' + item.number,
is_review: isReview,
is_assigned: assigned,
has_update: false,
...(due ? { due_label: due.label } : {}),
reason: priorityLabel ? priorityLabel + ' priority' :
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')),
_priority: priorityLabel ? 0 : (isReview ? 2 : (assigned ? 3 : 4)),
(due && due.priority < 4 ? due.label :
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))),
_priority: priorityLabel ? 0 :
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
};
});
@@ -286,27 +306,41 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
};
}
function createNotificationReplier({ post, storage, onStatus }) {
function createNotificationReplier({
post, storage, onStatus,
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
}) {
let pending = false;
const keyFor = item => 'stackchain.update-reply.v1.' + item.notification_id;
const operationKeyFor = item => keyFor(item) + ':operation';
return {
loadDraft(item) {
try { return storage.getItem(keyFor(item)) || ''; }
catch (_error) { return ''; }
},
saveDraft(item, body) {
try { storage.setItem(keyFor(item), body); }
try {
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
storage.setItem(keyFor(item), body);
}
catch (_error) { /* Keep the editable textarea as the fallback. */ }
},
async submit(item, body) {
if (pending) return false;
pending = true;
this.saveDraft(item, body);
let operationId;
try {
operationId = storage.getItem(operationKeyFor(item)) || String(createOperationId()).slice(0, 128);
storage.setItem(operationKeyFor(item), operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
onStatus('Sending reply…');
try {
const result = await post(item.notification_id, body);
const result = await post(item.notification_id, body, operationId);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
try { storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
onStatus('Reply posted. You can mark this update read when ready.');
return result;
} catch (_error) {
@@ -326,6 +360,84 @@ function filterMyWork(items, selectedFilter) {
return items.filter((item) => item.kind === selectedFilter);
}
function workIdentity(item) {
if (!item) return '';
const kind = item.is_review ? 'review' : (item.kind || 'work');
const repository = item.repository || '';
const number = Number.isInteger(item.number) ? item.number : '';
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
return [kind, repository, number, notification].join(':');
}
function createWorkSession({ getItems, getFilter, onOpen, onProgress, onFinish }) {
let currentIdentity = '';
let currentIndex = -1;
let running = false;
const queue = () => filterMyWork(getItems() || [], getFilter());
const report = (items, index) => onProgress({
index: index + 1,
total: items.length,
can_previous: index > 0,
can_next: index < items.length - 1,
});
const finish = () => {
running = false;
currentIdentity = '';
currentIndex = -1;
onFinish();
return false;
};
const openAt = (items, index) => {
if (!items.length || index < 0 || index >= items.length) return finish();
currentIndex = index;
currentIdentity = workIdentity(items[index]);
report(items, index);
onOpen(items[index]);
return true;
};
return {
active: () => running,
start() {
const items = queue();
if (!items.length) return finish();
running = true;
return openAt(items, 0);
},
reconcile() {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
if (index < 0) return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
currentIndex = index;
report(items, index);
return true;
},
previous() {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
return index > 0 ? openAt(items, index - 1) : false;
},
next() {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
},
complete() {
if (!running) return false;
const items = queue();
const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
if (stillPresent >= 0) {
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
}
return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
},
};
}
function replaceIssueLabels(data, repository, number, labels) {
return {
...data,
@@ -335,6 +447,24 @@ function replaceIssueLabels(data, repository, number, labels) {
};
}
function replaceIssueContent(data, repository, number, content) {
return {
...data,
issues: (data.issues || []).map(item =>
item.repository === repository && item.number === number ? { ...item, ...content } : item
),
};
}
function replaceIssueDueDate(data, repository, number, dueDate) {
return {
...data,
issues: (data.issues || []).map(item =>
item.repository === repository && item.number === number ? { ...item, due_date: dueDate } : item
),
};
}
function removeIssue(data, repository, number) {
return {
...data,
@@ -366,7 +496,11 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.createWorkSession = createWorkSession;
buildMyWork.workIdentity = workIdentity;
buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.replaceIssueContent = replaceIssueContent;
buildMyWork.replaceIssueDueDate = replaceIssueDueDate;
buildMyWork.removeIssue = removeIssue;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.countMyWork = countMyWork;
+68 -4
View File
@@ -1,4 +1,4 @@
function mergeEligibility(detail) {
function mergeEligibility(detail, reviewState) {
if (!detail || detail.state !== 'open' || detail.merged) {
return { allowed: false, reason: 'Pull request is not open' };
}
@@ -8,38 +8,101 @@ function mergeEligibility(detail) {
return { allowed: false, reason: 'CI must succeed before merging' };
}
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
if (reviewState && !reviewState.complete) {
return { allowed: false, reason: 'Review every changed file before merging' };
}
return { allowed: true, reason: 'Ready to merge' };
}
function createPullSheet({ fetchJson, storage }) {
function renderFile(file, index, reviewed, escapeHtml) {
const filename = escapeHtml(file.filename || 'Unknown file');
const panelId = 'pull-diff-' + index;
const lines = (file.diff_lines || []).map(line => {
const text = String(line);
const kind = text.startsWith('@@') ? 'hunk' : text.startsWith('+') ? 'added' :
text.startsWith('-') ? 'removed' : 'context';
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(text) + '</span>';
}).join('');
const preview = file.diff_available
? '<pre class="pull-diff" id="' + panelId + '" hidden>' + lines +
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
: '<div class="pull-diff-empty" id="' + panelId + '" hidden>' +
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') +
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
return '<article class="pull-file" data-pull-filename="' + filename + '">' +
'<button type="button" class="pull-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
'<strong>' + filename + '</strong><span class="small">' + escapeHtml(file.status || 'changed') +
' · +' + Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span></button>' + preview +
'<button type="button" class="pull-review-file" data-pull-review-file="' + filename +
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
}
function createPullSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
const operationKey = item => draftKey(item) + ':operation';
const reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha;
const fileNames = detail => (detail?.files || []).map(file => file.filename).filter(Boolean);
function reviewState(item, detail) {
const files = fileNames(detail);
let saved = [];
try {
const value = JSON.parse(storage?.getItem(reviewKey(item, detail)) || '[]');
if (Array.isArray(value)) saved = value;
} catch (_error) { /* Corrupt progress safely starts over. */ }
const reviewed = files.filter(filename => saved.includes(filename));
return { reviewed, total: files.length, complete: reviewed.length === files.length };
}
return {
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
reviewState,
toggleReviewed(item, detail, filename) {
const state = reviewState(item, detail);
const reviewed = new Set(state.reviewed);
if (reviewed.has(filename)) reviewed.delete(filename);
else if (fileNames(detail).includes(filename)) reviewed.add(filename);
try { storage?.setItem(reviewKey(item, detail), JSON.stringify(Array.from(reviewed))); }
catch (_error) { /* In-memory controls still work for this render. */ }
return reviewState(item, detail);
},
nextUnreviewed(detail, state) {
return fileNames(detail).find(filename => !state.reviewed.includes(filename)) || null;
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
},
saveDraft(item, body) {
try { storage?.setItem(draftKey(item), body); }
try {
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
storage?.setItem(draftKey(item), body);
}
catch (_error) { /* The textarea remains the fallback. */ }
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
let operationId;
try {
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
storage?.setItem(operationKey(item), operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
commentRequest = fetchJson(pathFor(item) + '/comments', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
body: JSON.stringify({ body }),
}).then(result => {
try { storage?.removeItem(draftKey(item)); }
catch (_error) { /* The upstream comment is authoritative. */ }
try { storage?.removeItem(operationKey(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
return result;
}).finally(() => { commentRequest = null; });
return commentRequest;
@@ -60,4 +123,5 @@ function createPullSheet({ fetchJson, storage }) {
}
createPullSheet.mergeEligibility = mergeEligibility;
createPullSheet.renderFile = renderFile;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
+22 -2
View File
@@ -1,4 +1,8 @@
function createReviewController({ fetchJson }) {
function createReviewController({
fetchJson,
storage,
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
}) {
let pendingSubmission = null;
function endpoint(item) {
@@ -16,10 +20,26 @@ function createReviewController({ fetchJson }) {
function submit(item, payload) {
if (pendingSubmission) return pendingSubmission;
const operationKey = 'stackchain.review-submit.v1:' + item.repository + '#' + item.number;
const fingerprint = JSON.stringify(payload);
let operationId;
try {
const saved = JSON.parse(storage?.getItem(operationKey) || 'null');
operationId = saved?.fingerprint === fingerprint && saved?.operationId
? saved.operationId : String(createOperationId()).slice(0, 128);
storage?.setItem(operationKey, JSON.stringify({ fingerprint, operationId }));
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
pendingSubmission = fetchJson(endpoint(item), {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': operationId,
},
body: JSON.stringify(payload),
}).then(result => {
try { storage?.removeItem(operationKey); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
return result;
}).finally(() => { pendingSubmission = null; });
return pendingSubmission;
}
+97 -1
View File
@@ -42,6 +42,10 @@ class IssueNotAvailableError(ValueError):
"""Raised before assignment when an issue is no longer open and unassigned."""
class IssueEditConflictError(ValueError):
"""Raised when an issue changed after the editor loaded it."""
def _auth() -> dict[str, str]:
headers: dict[str, str] = {"Accept": "application/json"}
if GITEA_TOKEN:
@@ -728,6 +732,39 @@ async def update_issue_labels(repository: str, number: int, label_ids: list[int]
}
async def update_assigned_issue_due_date(
repository: str, number: int, due_date: str | None
) -> dict:
path = f"repos/{repository}/issues/{number}"
login, issue = await _current_login_and_target(path)
if (
issue.get("state") != "open"
or isinstance(issue.get("pull_request"), dict)
or not _login_in_users(login, issue.get("assignees"))
):
raise IssueNotAvailableError("assigned issue not found")
payload = {"due_date": due_date} if due_date else {"unset_due_date": True}
response = await _get_client().patch(
f"/api/v1/{path}", headers=_auth(), json=payload
)
response.raise_for_status()
confirmed = response.json()
confirmed_due_date = confirmed.get("due_date") if isinstance(confirmed, dict) else None
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or confirmed_due_date != due_date
):
raise ValueError("Gitea did not confirm the issue due date update")
return {
"repository": repository,
"number": number,
"state": confirmed.get("state", "open"),
"due_date": confirmed_due_date,
}
async def issue_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/issues/{number}"
issue, comments = await asyncio.gather(
@@ -751,6 +788,8 @@ async def issue_detail(repository: str, number: int) -> dict:
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
"state": issue.get("state", "") if isinstance(issue.get("state"), str) else "",
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
"updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "",
"due_date": issue.get("due_date") if isinstance(issue.get("due_date"), str) else None,
"url": _safe_web_url(issue.get("html_url")),
"labels": [
label["name"] for label in labels
@@ -764,6 +803,49 @@ async def issue_detail(repository: str, number: int) -> dict:
}
async def update_assigned_issue(
repository: str,
number: int,
title: str,
body: str,
expected_updated_at: str,
) -> dict:
path = f"repos/{repository}/issues/{number}"
login, issue = await _current_login_and_target(path)
if (
issue.get("state") != "open"
or isinstance(issue.get("pull_request"), dict)
or not _login_in_users(login, issue.get("assignees"))
):
raise IssueNotAvailableError("assigned issue not found")
if issue.get("updated_at") != expected_updated_at:
raise IssueEditConflictError("issue changed upstream")
response = await _get_client().patch(
f"/api/v1/{path}",
headers=_auth(),
json={"title": title, "body": body},
)
response.raise_for_status()
confirmed = response.json()
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or confirmed.get("title") != title
or confirmed.get("body", "") != body
):
raise ValueError("Gitea did not confirm the issue content update")
return {
"repository": repository,
"number": number,
"title": title,
"body": body,
"state": confirmed.get("state", "open"),
"updated_at": confirmed.get("updated_at", ""),
"url": _safe_web_url(confirmed.get("html_url")),
}
async def _current_login_and_target(path: str) -> tuple[str, dict]:
user, target = await asyncio.gather(current_user(), fetch(path))
login = user.get("login") if isinstance(user, dict) else None
@@ -842,11 +924,16 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
raise ValueError("Gitea pull request response was not an object")
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
sha = head.get("sha") if isinstance(head.get("sha"), str) else ""
files, status, comments = await asyncio.gather(
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,
@@ -867,6 +954,15 @@ async def pull_completion_detail(repository: str, number: int) -> dict:
"status": item.get("status") or "changed",
"additions": item.get("additions") or 0,
"deletions": item.get("deletions") or 0,
**previews.get(
item["filename"],
{
"diff_lines": [],
"diff_available": False,
"diff_binary": False,
"diff_truncated": diff_truncated,
},
),
}
for item in (files if isinstance(files, list) else [])[:100]
if isinstance(item, dict) and isinstance(item.get("filename"), str)
+194 -13
View File
@@ -1,7 +1,7 @@
import asyncio
import math
import time
from collections.abc import Awaitable
from collections.abc import Awaitable, Coroutine
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, Literal
@@ -61,6 +61,8 @@ REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
ISSUE_ACTION_TIMEOUT_SECONDS = 5.0
ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS = 600.0
ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES = 256
AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS = 600.0
AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES = 256
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
WORK_PAGE_TIMEOUT_SECONDS = 5.0
@@ -90,6 +92,9 @@ _read_notification_ids: set[int] = set()
_issue_creation_operations: dict[
str, tuple[tuple[Any, ...], asyncio.Task, float]
] = {}
_authored_action_operations: dict[
str, tuple[tuple[Any, ...], asyncio.Task, float]
] = {}
_available_issue_snapshot_task: asyncio.Task | None = None
_available_issue_snapshot_value: list[dict] | None = None
_available_issue_snapshot_created_at: float | None = None
@@ -150,10 +155,37 @@ class IssueCreation(BaseModel):
return value.strip()
class IssueContentUpdate(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
expected_updated_at: str = Field(min_length=1, max_length=64)
@field_validator("title")
@classmethod
def strip_title(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("title must not be blank")
return value
@field_validator("body")
@classmethod
def strip_body(cls, value: str) -> str:
return value.strip()
class IssueLabelUpdate(BaseModel):
label_ids: list[PositiveInt] = Field(max_length=20)
class IssueDueDateUpdate(BaseModel):
due_date: str | None = Field(
default=None,
pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$",
max_length=20,
)
class PullReviewComment(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
body: str = Field(min_length=1, max_length=10_000)
@@ -193,6 +225,52 @@ class PullMergeSubmission(BaseModel):
expected_head_sha: str = Field(min_length=1, max_length=128)
async def _run_idempotent_authored_action(
operation: Coroutine[Any, Any, Any],
*,
idempotency_key: str | None,
fingerprint: tuple[Any, ...],
timeout: float,
):
if not idempotency_key:
return await asyncio.wait_for(operation, timeout=timeout)
now = time.monotonic()
expired = [
key for key, (_, task, created_at) in _authored_action_operations.items()
if task.done() and now - created_at >= AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS
]
for key in expired:
_authored_action_operations.pop(key, None)
existing = _authored_action_operations.get(idempotency_key)
if existing is not None:
operation.close()
if existing[0] != fingerprint:
raise HTTPException(status_code=409, detail="Idempotency key already used")
task = existing[1]
else:
while len(_authored_action_operations) >= AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES:
completed = [
key for key, (_, task, _) in _authored_action_operations.items()
if task.done()
]
if not completed:
operation.close()
raise HTTPException(
status_code=503,
detail="Authored action queue is busy; please retry",
headers={"Retry-After": "1"},
)
oldest = min(
completed, key=lambda key: _authored_action_operations[key][2]
)
_authored_action_operations.pop(oldest)
task = asyncio.create_task(operation)
_authored_action_operations[idempotency_key] = (fingerprint, task, now)
return await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
user_model = User(
id=user_data["id"],
@@ -216,7 +294,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)],
assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, dict)],
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
updated_at=i.get("updated_at") or "", url=i["html_url"],
updated_at=i.get("updated_at") or "",
due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None,
url=i["html_url"],
)
for i in (issues_data or [])[:50]
if isinstance(i, dict)
@@ -254,7 +334,9 @@ def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
updated_at=item.get("updated_at") or "", url=item["html_url"],
updated_at=item.get("updated_at") or "",
due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None,
url=item["html_url"],
).model_dump()
for item in items
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
@@ -908,13 +990,19 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
@app.post("/api/v1/notifications/{thread_id}/reply", status_code=201)
async def reply_to_notification(
reply: NotificationReply, thread_id: int = PathParam(gt=0)
reply: NotificationReply,
thread_id: int = PathParam(gt=0),
idempotency_key: str | None = Header(default=None, max_length=128),
) -> JSONResponse:
try:
result = await asyncio.wait_for(
result = await _run_idempotent_authored_action(
gitea_proxy.reply_to_notification(thread_id, reply.body),
idempotency_key=idempotency_key,
fingerprint=("notification-reply", thread_id, reply.body),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{
@@ -1013,6 +1101,69 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g
)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content")
async def update_assigned_issue_content(
update: IssueContentUpdate,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.update_assigned_issue(
repository,
number,
update.title,
update.body,
update.expected_updated_at,
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueEditConflictError:
return JSONResponse(
{"error": "This issue changed in Gitea. Your draft is safe; reload the latest issue before saving."},
status_code=409,
)
except gitea_proxy.IssueNotAvailableError:
raise HTTPException(status_code=404, detail="Assigned issue not found")
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The issue could not be updated. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/due-date")
async def update_assigned_issue_due_date(
update: IssueDueDateUpdate,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.update_assigned_issue_due_date(
repository, number, update.due_date
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
raise HTTPException(status_code=404, detail="Assigned issue not found")
except Exception:
return JSONResponse(
{"error": "The due date could not be updated. Your selection is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/labels")
async def repository_labels(owner: str, repo: str):
repository = f"{owner}/{repo}"
@@ -1129,7 +1280,11 @@ async def create_assigned_issue(
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", status_code=201)
async def comment_on_assigned_issue(
comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0)
comment: IssueComment,
owner: str,
repo: str,
number: int = PathParam(gt=0),
idempotency_key: str | None = Header(default=None, max_length=128),
):
repository = f"{owner}/{repo}"
@@ -1139,7 +1294,12 @@ async def comment_on_assigned_issue(
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
try:
result = await asyncio.wait_for(post_comment(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
result = await _run_idempotent_authored_action(
post_comment(),
idempotency_key=idempotency_key,
fingerprint=("issue-comment", repository, number, comment.body),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
@@ -1292,7 +1452,11 @@ async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", status_code=201)
async def comment_on_assigned_pull(
comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0)
comment: IssueComment,
owner: str,
repo: str,
number: int = PathParam(gt=0),
idempotency_key: str | None = Header(default=None, max_length=128),
):
repository = f"{owner}/{repo}"
@@ -1302,8 +1466,11 @@ async def comment_on_assigned_pull(
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
try:
result = await asyncio.wait_for(
post_comment(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
result = await _run_idempotent_authored_action(
post_comment(),
idempotency_key=idempotency_key,
fingerprint=("pull-comment", repository, number, comment.body),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except HTTPException:
raise
@@ -1358,7 +1525,11 @@ async def merge_assigned_pull(
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
async def submit_review(
submission: PullReviewSubmission, owner: str, repo: str, number: int
submission: PullReviewSubmission,
owner: str,
repo: str,
number: int,
idempotency_key: str | None = Header(default=None, max_length=128),
):
repository = f"{owner}/{repo}"
@@ -1380,8 +1551,18 @@ async def submit_review(
return await gitea_proxy.submit_pull_review(*args)
try:
result = await asyncio.wait_for(
submit_requested_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
comment_fingerprint = tuple(
(comment.path, comment.body, comment.new_position, comment.old_position)
for comment in submission.comments
)
result = await _run_idempotent_authored_action(
submit_requested_review(),
idempotency_key=idempotency_key,
fingerprint=(
"pull-review", repository, number, submission.expected_head_sha,
submission.decision, submission.body, comment_fingerprint,
),
timeout=REVIEW_DETAIL_TIMEOUT_SECONDS,
)
except HTTPException:
raise
+1
View File
@@ -26,6 +26,7 @@ class Issue(BaseModel):
assignees: list[str] = []
repository: str = ""
updated_at: str = ""
due_date: str | None = None
url: str
+142
View File
@@ -0,0 +1,142 @@
import asyncio
import httpx
import pytest
from src import main
@pytest.fixture(autouse=True)
def clear_authored_operations():
main._authored_action_operations.clear()
yield
main._authored_action_operations.clear()
@pytest.mark.anyio
async def test_issue_comment_replays_one_upstream_result_for_concurrent_requests(monkeypatch):
calls = 0
started = asyncio.Event()
release = asyncio.Event()
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
nonlocal calls
calls += 1
started.set()
await release.wait()
return {"id": 82, "body": body}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "issue-comment-185"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues/7/comments",
json={"body": "Ship it"}, headers=headers,
))
await started.wait()
second = asyncio.create_task(client.post(
"/api/v1/repos/stackchain/api/issues/7/comments",
json={"body": "Ship it"}, headers=headers,
))
await asyncio.sleep(0)
release.set()
responses = await asyncio.gather(first, second)
assert [response.status_code for response in responses] == [201, 201]
assert calls == 1
@pytest.mark.anyio
async def test_pull_comment_rejects_changed_payload_for_same_key(monkeypatch):
calls = []
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, body):
calls.append(body)
return {"id": len(calls), "body": body}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "pull-comment-185"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/comments",
json={"body": "Ship it"}, headers=headers,
)
conflict = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/comments",
json={"body": "Changed"}, headers=headers,
)
assert created.status_code == 201
assert conflict.status_code == 409
assert calls == ["Ship it"]
@pytest.mark.anyio
async def test_notification_reply_retry_recovers_after_caller_timeout(monkeypatch):
calls = 0
async def reply(_thread_id, body):
nonlocal calls
calls += 1
await asyncio.sleep(0.03)
return {"id": 91, "body": body}
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "notification-reply-185"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
timed_out = await client.post(
"/api/v1/notifications/42/reply", json={"body": "Retry"}, headers=headers,
)
await asyncio.sleep(0.03)
recovered = await client.post(
"/api/v1/notifications/42/reply", json={"body": "Retry"}, headers=headers,
)
assert timed_out.status_code == 503
assert recovered.status_code == 201
assert recovered.json()["id"] == 91
assert calls == 1
@pytest.mark.anyio
async def test_review_replays_same_key_and_rejects_changed_head(monkeypatch):
calls = []
async def requested(_repository, _number):
return True
async def submit(_repository, _number, head, decision, body):
calls.append((head, decision, body))
return {"id": 93, "state": "APPROVED"}
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "review-185"}
payload = {"expected_head_sha": "abc", "decision": "approve", "body": "Good"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/review", json=payload, headers=headers,
)
replayed = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/review", json=payload, headers=headers,
)
conflict = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/review",
json={**payload, "expected_head_sha": "def"}, headers=headers,
)
assert [created.status_code, replayed.status_code, conflict.status_code] == [201, 201, 409]
assert calls == [("abc", "approve", "Good")]
+2
View File
@@ -421,6 +421,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
"assignees": [{"login": "timmy"}],
"repository": {"full_name": "stackchain/mobile"},
"updated_at": "2026-08-06T12:00:00Z",
"due_date": "2026-08-09T23:59:59Z",
"html_url": "https://forge.example/stackchain/mobile/issues/7",
}]
@@ -448,6 +449,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
assert payload["issues"][0]["repository"] == "stackchain/mobile"
assert payload["issues"][0]["updated_at"] == "2026-08-06T12:00:00Z"
assert payload["issues"][0]["due_date"] == "2026-08-09T23:59:59Z"
assert payload["pull_requests"][0]["repository"] == "stackchain/api"
assert payload["pull_requests"][0]["updated_at"] == "2026-08-06T11:00:00Z"
assert payload["pull_requests"][0]["labels"] == ["priority-high"]
+221
View File
@@ -1,4 +1,5 @@
import asyncio
import json
import httpx
import pytest
@@ -6,6 +7,223 @@ import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
@pytest.mark.parametrize(
("due_date", "expected_payload", "confirmed_due_date"),
[
("2026-08-09T23:59:59Z", {"due_date": "2026-08-09T23:59:59Z"}, "2026-08-09T23:59:59Z"),
(None, {"unset_due_date": True}, None),
],
)
async def test_gitea_due_date_update_revalidates_assignment_and_confirms_result(
due_date, expected_payload, confirmed_due_date
):
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.method == "GET":
return httpx.Response(200, json={
"number": 17, "state": "open", "pull_request": None,
"assignees": [{"login": "timmy"}],
})
return httpx.Response(200, json={
"number": 17, "state": "open", "due_date": confirmed_due_date,
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.update_assigned_issue_due_date(
"stackchain/api", 17, due_date
)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
]
assert json.loads(requests[2].content) == expected_payload
assert result == {
"repository": "stackchain/api", "number": 17,
"state": "open", "due_date": confirmed_due_date,
}
@pytest.mark.anyio
async def test_due_date_endpoint_sets_or_clears_assigned_issue(monkeypatch):
calls = []
async def update(repository, number, due_date):
calls.append((repository, number, due_date))
return {
"repository": repository, "number": number, "state": "open",
"due_date": due_date,
}
monkeypatch.setattr(
main.gitea_proxy, "update_assigned_issue_due_date", update, raising=False
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
set_response = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/due-date",
json={"due_date": "2026-08-09T23:59:59Z"},
)
clear_response = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/due-date",
json={"due_date": None},
)
assert [set_response.status_code, clear_response.status_code] == [200, 200]
assert set_response.headers["cache-control"] == "no-store"
assert set_response.json()["due_date"] == "2026-08-09T23:59:59Z"
assert clear_response.json()["due_date"] is None
assert calls == [
("stackchain/api", 17, "2026-08-09T23:59:59Z"),
("stackchain/api", 17, None),
]
@pytest.mark.anyio
async def test_edit_assigned_issue_updates_title_and_body_at_expected_revision(monkeypatch):
calls = []
async def update(repository, number, title, body, expected_updated_at):
calls.append((repository, number, title, body, expected_updated_at))
return {
"repository": repository,
"number": number,
"title": title,
"body": body,
"state": "open",
"updated_at": "2026-08-07T10:01:00Z",
}
monkeypatch.setattr(
main.gitea_proxy, "update_assigned_issue", update, raising=False
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/content",
json={
"title": " Clarified scope ",
"body": " Updated acceptance criteria ",
"expected_updated_at": "2026-08-07T10:00:00Z",
},
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["title"] == "Clarified scope"
assert calls == [(
"stackchain/api", 17, "Clarified scope", "Updated acceptance criteria",
"2026-08-07T10:00:00Z",
)]
@pytest.mark.anyio
async def test_gitea_edit_issue_rejects_stale_revision_without_patch():
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"number": 17,
"title": "Changed upstream",
"body": "Newer body",
"state": "open",
"updated_at": "2026-08-07T10:02:00Z",
"assignees": [{"login": "timmy"}],
"pull_request": None,
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(gitea_proxy.IssueEditConflictError):
await gitea_proxy.update_assigned_issue(
"stackchain/api", 17, "My draft", "Draft body",
"2026-08-07T10:00:00Z",
)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/17"),
]
@pytest.mark.anyio
async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.method == "GET":
return httpx.Response(200, json={
"number": 17, "title": "Old", "body": "Old body", "state": "open",
"updated_at": "2026-08-07T10:00:00Z",
"assignees": [{"login": "timmy"}], "pull_request": None,
})
return httpx.Response(200, json={
"number": 17, "title": "Clarified", "body": "New body", "state": "open",
"updated_at": "2026-08-07T10:01:00Z",
"html_url": "https://forge.example/stackchain/api/issues/17",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.update_assigned_issue(
"stackchain/api", 17, "Clarified", "New body", "2026-08-07T10:00:00Z"
)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
]
assert requests[2].content == b'{"title":"Clarified","body":"New body"}'
assert result == {
"repository": "stackchain/api", "number": 17, "title": "Clarified",
"body": "New body", "state": "open", "updated_at": "2026-08-07T10:01:00Z",
"url": "https://forge.example/stackchain/api/issues/17",
}
@pytest.mark.anyio
async def test_edit_assigned_issue_reports_revision_conflict_without_mutation(monkeypatch):
async def update(*_args):
raise gitea_proxy.IssueEditConflictError("changed")
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue", update)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/content",
json={
"title": "My draft",
"body": "Still safe",
"expected_updated_at": "2026-08-07T10:00:00Z",
},
)
assert response.status_code == 409
assert response.json() == {
"error": "This issue changed in Gitea. Your draft is safe; reload the latest issue before saving."
}
@pytest.fixture(autouse=True)
def clear_issue_creation_operations():
main._issue_creation_operations.clear()
@@ -749,6 +967,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"title": "Fix mobile flow",
"state": "open",
"body": "Full issue context",
"updated_at": "2026-08-07T09:59:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7",
"labels": [{"name": "P1"}],
"assignees": [{"login": "timmy"}],
@@ -771,6 +990,8 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"title": "Fix mobile flow",
"state": "open",
"body": "Full issue context",
"updated_at": "2026-08-07T09:59:00Z",
"due_date": None,
"url": "https://forge.example/stackchain/api/issues/7",
"labels": ["P1"],
"assignees": ["timmy"],
+413 -1
View File
@@ -78,6 +78,38 @@ process.stdout.write(JSON.stringify(queue));
assert queue[2]["reason"] == "Assigned to you"
def test_my_work_surfaces_and_ranks_due_issues_after_p0_before_ordinary_work():
payload = {
"user": {"login": "timmy"},
"issues": [
{"number": 1, "title": "Ordinary", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-07T12:00:00Z"},
{"number": 2, "title": "Due today", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-07T23:59:59Z"},
{"number": 3, "title": "Overdue", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z"},
{"number": 4, "title": "P0 future", "repository": "stackchain/api",
"labels": ["P0"], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
],
"pull_requests": [],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const queue = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'));
process.stdout.write(JSON.stringify(queue.map(item => ({{title:item.title,reason:item.reason,due_label:item.due_label}}))));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == [
{"title": "P0 future", "reason": "P0 priority", "due_label": "Due Aug 10"},
{"title": "Overdue", "reason": "Overdue", "due_label": "Overdue"},
{"title": "Due today", "reason": "Due today", "due_label": "Due today"},
{"title": "Ordinary", "reason": "Assigned to you"},
]
def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue():
payload = {
"user": {"login": "timmy"},
@@ -110,6 +142,34 @@ process.stdout.write(JSON.stringify({{
}
def test_confirmed_issue_content_updates_my_work_without_mutating_snapshot():
payload = {
"issues": [{"number": 17, "repository": "stackchain/api", "title": "Old", "body": "Old body"}],
"pull_requests": [],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = {json.dumps(payload)};
const updated = buildMyWork.replaceIssueContent(
original, 'stackchain/api', 17,
{{title:'Clarified',body:'New body',updated_at:'2026-08-07T10:01:00Z'}}
);
process.stdout.write(JSON.stringify({{updated:updated.issues[0],original:original.issues[0]}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"updated": {
"number": 17, "repository": "stackchain/api", "title": "Clarified",
"body": "New body", "updated_at": "2026-08-07T10:01:00Z",
},
"original": {
"number": 17, "repository": "stackchain/api", "title": "Old", "body": "Old body",
},
}
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -155,6 +215,112 @@ controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
assert result.stdout == "Issue release was not confirmed."
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let calls = [];
let finish;
const controller = createIssueSheet({{
storage,
fetchJson:(url, options) => {{
calls.push({{url, options}});
return new Promise(resolve => {{ finish = resolve; }});
}},
}});
const item = {{repository:'stackchain/api', number:17}};
const draft = {{title:'Clarified scope', body:'Updated body', expectedUpdatedAt:'2026-08-07T10:00:00Z'}};
controller.saveEditDraft(item, draft);
const first = controller.updateContent(item, draft);
const duplicate = controller.updateContent(item, draft);
const during = controller.loadEditDraft(item);
finish({{repository:'stackchain/api',number:17,title:'Clarified scope',body:'Updated body',updated_at:'2026-08-07T10:01:00Z'}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
same:first === duplicate, during, after:controller.loadEditDraft(item), results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/api/issues/17/content",
"method": "PATCH",
"body": {
"title": "Clarified scope", "body": "Updated body",
"expected_updated_at": "2026-08-07T10:00:00Z",
},
}]
assert output["same"] is True
assert output["during"] == {
"title": "Clarified scope", "body": "Updated body",
"expectedUpdatedAt": "2026-08-07T10:00:00Z",
}
assert output["after"] is None
assert output["results"][0]["updated_at"] == "2026-08-07T10:01:00Z"
def test_issue_due_date_update_is_single_flight_and_keeps_draft_until_confirmed():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let calls = [];
let finish;
const controller = createIssueSheet({{
storage,
fetchJson:(url, options) => {{
calls.push({{url, options}});
return new Promise(resolve => {{ finish = resolve; }});
}},
}});
const item = {{repository:'stackchain/api', number:17}};
const first = controller.updateDueDate(item, '2026-08-09T23:59:59Z');
const duplicate = controller.updateDueDate(item, '2026-08-09T23:59:59Z');
const during = controller.loadDueDateDraft(item);
finish({{repository:'stackchain/api',number:17,state:'open',due_date:'2026-08-09T23:59:59Z'}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
same:first === duplicate, during, after:controller.loadDueDateDraft(item), results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/api/issues/17/due-date",
"method": "PATCH",
"body": {"due_date": "2026-08-09T23:59:59Z"},
}]
assert output["same"] is True
assert output["during"] == "2026-08-09T23:59:59Z"
assert output["after"] is None
assert output["results"][0]["due_date"] == "2026-08-09T23:59:59Z"
@pytest.mark.anyio
async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_badge():
html = await dashboard()
assert 'id="issue-due-date" type="date"' in html
assert 'id="save-issue-due-date"' in html
assert 'id="clear-issue-due-date"' in html
assert 'class="pill due-badge"' in html
assert '.issue-due-editor input, .issue-due-editor button { min-height:44px;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
def test_my_work_reviews_filter_and_summary_are_actionable():
items = [
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
@@ -179,6 +345,100 @@ process.stdout.write(JSON.stringify({{
assert output["summary"] == "1 review · 2 assigned"
def test_mobile_work_session_follows_filter_and_reconciles_by_identity():
items = [
{"kind": "issue", "repository": "stackchain/api", "number": 1, "title": "First"},
{"kind": "pull", "repository": "stackchain/web", "number": 2, "title": "Second"},
{"kind": "issue", "repository": "stackchain/api", "number": 3, "title": "Third"},
]
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let items = {json.dumps(items)};
let filter = 'issue';
const opened = [];
const progress = [];
let finished = 0;
const session = buildMyWork.createWorkSession({{
getItems: () => items,
getFilter: () => filter,
onOpen: item => opened.push(item.title),
onProgress: state => progress.push(state),
onFinish: () => {{ finished += 1; }},
}});
session.start();
items = [items[2], items[1], items[0]];
session.reconcile();
session.previous();
items = items.filter(item => item.number !== 3);
session.complete();
items = [];
session.complete();
process.stdout.write(JSON.stringify({{opened, progress, finished, active:session.active()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["opened"] == ["First", "Third", "First"]
assert output["progress"] == [
{"index": 1, "total": 2, "can_previous": False, "can_next": True},
{"index": 2, "total": 2, "can_previous": True, "can_next": False},
{"index": 1, "total": 2, "can_previous": False, "can_next": True},
{"index": 1, "total": 1, "can_previous": False, "can_next": False},
]
assert output["finished"] == 1
assert output["active"] is False
def test_mobile_work_session_completion_finishes_when_current_item_is_last():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [{{kind:'pull',repository:'stackchain/api',number:9,is_review:true}}];
let finished = 0;
const session = buildMyWork.createWorkSession({{
getItems: () => items, getFilter: () => 'review', onOpen: () => {{}},
onProgress: () => {{}}, onFinish: () => {{ finished += 1; }},
}});
session.start();
session.complete();
process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {"finished": 1, "active": False}
@pytest.mark.anyio
async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet():
html = await dashboard()
assert 'id="start-work-session"' in html
assert html.count('class="work-session-nav"') == 4
assert html.count('<span class="small" aria-live="polite" data-work-session-progress>') == 4
assert html.count('<button type="button" data-work-session-previous>') == 4
assert html.count('<button type="button" data-work-session-next>') == 4
assert '.work-session-nav button { min-height:44px;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
assert 'aria-live="polite" data-work-session-progress' in html
@pytest.mark.anyio
async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions():
html = await dashboard()
assert 'const workSession = createWorkSession({' in html
assert 'function openWorkSessionItem(item)' in html
assert "qs('#start-work-session').addEventListener('click'" in html
assert "document.querySelectorAll('[data-work-session-previous]')" in html
assert "document.querySelectorAll('[data-work-session-next]')" in html
assert 'workSession.complete();' in html
assert 'workSession.reconcile();' in html
for opener in ('openIssueSheet(item', 'openPullSheet(item', 'openReviewSheet(item', 'notificationReader.open(item'):
assert opener in html
def test_my_work_filter_counts_distinguish_prs_from_review_requests():
items = [
{"kind": "issue", "is_review": False},
@@ -415,6 +675,22 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control():
assert '.load-more-work { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
html = await dashboard()
assert 'id="edit-issue-content"' in html
assert 'id="issue-edit-form"' in html
assert 'id="issue-edit-title" type="text" maxlength="255"' in html
assert 'id="issue-edit-body" maxlength="10000"' in html
assert 'id="save-issue-content"' in html
assert 'id="cancel-issue-content"' in html
assert 'id="retry-issue-load" type="button" hidden>Reload latest issue</button>' in html
assert 'id="issue-edit-status" class="small" aria-live="assertive"' in html
assert '.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px;' in html
assert 'buildMyWork.replaceIssueContent(' in html
@pytest.mark.anyio
async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe():
html = await dashboard()
@@ -1220,6 +1496,65 @@ process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)
assert output[3]["allowed"] is False and "conflict" in output[3]["reason"].lower()
def test_pull_sheet_persists_head_scoped_file_review_and_gates_merge():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const item = {{repository:'stackchain/api', number:7}};
const detail = {{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'abc', files:[
{{filename:'src/api.py'}}, {{filename:'frontend/app.js'}}
]}};
const first = createPullSheet({{storage, fetchJson:()=>Promise.resolve()}});
const before = first.reviewState(item, detail);
const afterOne = first.toggleReviewed(item, detail, 'src/api.py');
const restored = createPullSheet({{storage, fetchJson:()=>Promise.resolve()}}).reviewState(item, detail);
const complete = first.toggleReviewed(item, detail, 'frontend/app.js');
const changedHead = first.reviewState(item, {{...detail, head_sha:'def'}});
process.stdout.write(JSON.stringify({{
before, afterOne, restored, complete, changedHead,
blocked:createPullSheet.mergeEligibility(detail, afterOne),
allowed:createPullSheet.mergeEligibility(detail, complete),
next:first.nextUnreviewed(detail, afterOne),
}}));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert output["before"] == {"reviewed": [], "total": 2, "complete": False}
assert output["afterOne"]["reviewed"] == ["src/api.py"]
assert output["restored"] == output["afterOne"]
assert output["complete"]["complete"] is True
assert output["changedHead"]["reviewed"] == []
assert output["blocked"] == {"allowed": False, "reason": "Review every changed file before merging"}
assert output["allowed"] == {"allowed": True, "reason": "Ready to merge"}
assert output["next"] == "frontend/app.js"
def test_pull_sheet_renders_mobile_diff_fallbacks_and_review_controls():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const escapeHtml = value => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;');
const text = createPullSheet.renderFile({{
filename:'src/<unsafe>.py', status:'modified', additions:1, deletions:1,
diff_available:true, diff_lines:['@@ -1 +1 @@', '-old', '+new'], diff_truncated:true
}}, 0, false, escapeHtml);
const binary = createPullSheet.renderFile({{
filename:'static/logo.png', diff_available:false, diff_binary:true, diff_truncated:false
}}, 1, true, escapeHtml);
process.stdout.write(JSON.stringify({{text, binary}}));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert 'aria-controls="pull-diff-0"' in output["text"]
assert "&lt;unsafe>" in output["text"] and "+new" in output["text"]
assert "Preview truncated" in output["text"]
assert 'data-pull-review-file="src/&lt;unsafe>.py"' in output["text"]
assert "Binary file · preview unavailable" in output["binary"]
assert 'aria-pressed="true"' in output["binary"] and "Reviewed" in output["binary"]
@pytest.mark.anyio
async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
html = await dashboard()
@@ -1228,10 +1563,17 @@ async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
assert 'class="my-work-card-main pull-trigger"' in html
assert 'id="pull-sheet-status"' in html
assert 'id="pull-files"' in html and 'id="pull-comments"' in html
assert 'id="pull-review-progress"' in html and 'aria-live="polite"' in html
assert 'id="next-unreviewed-pull-file"' in html
assert 'id="pull-comment"' in html and 'maxlength="10000"' in html
assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html
assert '<script src="static/pull-sheet.js"></script>' in html
assert "pullController.load(item)" in html
assert "createPullSheet.renderFile" in html
assert "pullController.toggleReviewed" in html
assert "pullController.nextUnreviewed" in html
assert ".pull-diff { overflow-x:auto;" in html
assert ".pull-file-toggle, .pull-review-file { min-height:44px;" in html
assert "window.confirm('Merge ' + selectedPull.key" in html
assert "expected_head_sha" in html
assert "item.kind === 'pull'" in html and "pull-trigger" in html
@@ -1888,7 +2230,7 @@ def test_review_controller_submits_once_while_request_is_in_flight():
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
let resolveRequest;
const calls = [];
const controller = createReviewController({{ fetchJson: (url, options) => {{
const controller = createReviewController({{ createOperationId: () => 'review-op-test', fetchJson: (url, options) => {{
calls.push({{url, options}});
return new Promise(resolve => {{ resolveRequest = resolve; }});
}} }});
@@ -1914,6 +2256,7 @@ Promise.all([first, second]).then(results => process.stdout.write(JSON.stringify
"headers": {
"Accept": "application/json",
"Content-Type": "application/json",
"Idempotency-Key": "review-op-test",
},
"body": json.dumps(
{
@@ -1975,3 +2318,72 @@ async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item(
assert "qs('#retry-review-load').hidden = true" in html
assert "openReviewSheet(selectedReview, reviewTrigger)" in html
assert "qs('#retry-review-load').focus()" in html
def test_issue_comment_reuses_operation_key_after_reload_until_success():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const calls = [];
const item = {{repository:'stackchain/api', number:7}};
const first = createIssueSheet({{storage, createOperationId:() => 'issue-op-185', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.reject(new Error('timeout'));}}}});
first.comment(item, 'Ship it').catch(() => {{
const second = createIssueSheet({{storage, createOperationId:() => 'wrong-key', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.resolve({{id:82}});}}}});
second.comment(item, 'Ship it').then(() => process.stdout.write(JSON.stringify({{calls, keys:[...values.keys()]}})));
}});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {"calls": ["issue-op-185", "issue-op-185"], "keys": []}
def test_pull_comment_reuses_operation_key_after_reload_until_success():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const calls = [];
const item = {{repository:'stackchain/api', number:7}};
const first = createPullSheet({{storage, createOperationId:() => 'pull-op-185', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.reject(new Error('timeout'));}}}});
first.comment(item, 'Ship it').catch(() => {{
createPullSheet({{storage, createOperationId:() => 'wrong-key', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.resolve({{id:91}});}}}}).comment(item, 'Ship it')
.then(() => process.stdout.write(JSON.stringify({{calls, keys:[...values.keys()]}})));
}});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {"calls": ["pull-op-185", "pull-op-185"], "keys": []}
def test_notification_reply_reuses_persisted_operation_key():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const calls = [];
const item = {{notification_id:42}};
const first = build.createNotificationReplier({{storage, onStatus:()=>{{}}, createOperationId:() => 'reply-op-185', post:(_id,_body,key) => {{calls.push(key); return Promise.reject(new Error('timeout'));}}}});
first.submit(item, 'Retry').then(() => {{
const second = build.createNotificationReplier({{storage, onStatus:()=>{{}}, createOperationId:() => 'wrong-key', post:(_id,_body,key) => {{calls.push(key); return Promise.resolve({{id:91}});}}}});
second.submit(item, 'Retry').then(() => process.stdout.write(JSON.stringify({{calls, keys:[...values.keys()]}})));
}});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {"calls": ["reply-op-185", "reply-op-185"], "keys": []}
def test_review_submission_reuses_persisted_operation_key():
script = f"""
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
const calls = [];
const item = {{repository:'stackchain/api', number:7}};
const payload = {{decision:'approve', body:'Good', expected_head_sha:'abc', comments:[]}};
const first = createReviewController({{storage, createOperationId:() => 'review-op-185', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.reject(new Error('timeout'));}}}});
first.submit(item, payload).catch(() => {{
createReviewController({{storage, createOperationId:() => 'wrong-key', fetchJson:(_u,o) => {{calls.push(o.headers['Idempotency-Key']); return Promise.resolve({{id:93}});}}}}).submit(item, payload)
.then(() => process.stdout.write(JSON.stringify({{calls, keys:[...values.keys()]}})));
}});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {"calls": ["review-op-185", "review-op-185"], "keys": []}
+43
View File
@@ -53,6 +53,49 @@ async def test_assigned_pull_detail_rejects_unassigned_pull(monkeypatch):
assert response.status_code == 404
@pytest.mark.anyio
async def test_gitea_assigned_pull_detail_includes_bounded_diff_previews():
async def handler(request):
path = request.url.path
if path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7,
"title": "Review this patch",
"state": "open",
"mergeable": True,
"head": {"sha": "abc123"},
})
if path.endswith("/pulls/7/files"):
return httpx.Response(200, json=[
{"filename": "src/api.py", "status": "modified", "additions": 1, "deletions": 1},
{"filename": "static/logo.png", "status": "modified"},
])
if path.endswith("/commits/abc123/status"):
return httpx.Response(200, json={"state": "success"})
if path.endswith("/issues/7/comments"):
return httpx.Response(200, json=[])
if path.endswith("/pulls/7.diff"):
return httpx.Response(200, text=(
"diff --git a/src/api.py b/src/api.py\n"
"--- a/src/api.py\n+++ b/src/api.py\n"
"@@ -1 +1 @@\n-old\n+new\n"
"diff --git a/static/logo.png b/static/logo.png\n"
"Binary files a/static/logo.png and b/static/logo.png differ\n"
))
raise AssertionError(f"unexpected request: {request.method} {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 detail["files"][0]["diff_available"] is True
assert "+new" in detail["files"][0]["diff_lines"]
assert detail["files"][1]["diff_binary"] is True
assert detail["files"][1]["diff_available"] is False
@pytest.mark.anyio
async def test_assigned_pull_comment_posts_only_after_assignment_check(monkeypatch):
calls = []
+2
View File
@@ -20,6 +20,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
"labels": [], "assignees": [{"login": "timmy"}],
"repository": {"full_name": "stackchain/api"},
"updated_at": "2026-08-07T10:00:00Z",
"due_date": "2026-08-09T23:59:59Z",
"html_url": "https://forge.example/stackchain/api/issues/51",
}],
}
@@ -38,6 +39,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
"id": 51, "number": 51, "title": "Older issue", "state": "open",
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
"updated_at": "2026-08-07T10:00:00Z",
"due_date": "2026-08-09T23:59:59Z",
"url": "https://forge.example/stackchain/api/issues/51",
}],
}