Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5f63a6fa6 | ||
|
|
4cc00d2e48 | ||
|
|
f1448e6b58 | ||
|
|
2243b9453c | ||
|
|
fe834a37a4 | ||
|
|
46070d03f6 | ||
|
|
7651e5b090 | ||
|
|
de20406a45 | ||
|
|
2072de337e | ||
|
|
ecd4b55a26 | ||
|
|
c639b09aaf | ||
|
|
5406ee2297 | ||
|
|
937df5d20b | ||
|
|
c0eabbf865 | ||
|
|
2d8a659ebe | ||
|
|
3ff173ca89 | ||
|
|
0bc6e01eb5 | ||
|
|
4da3b198d0 |
No files matched your search
@@ -2,3 +2,4 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.release-engine/
|
||||
.stackchain-state/
|
||||
@@ -16,14 +16,19 @@ 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, reload, process restart, or handoff to
|
||||
another worker replays a confirmed result instead of posting duplicate content. Results
|
||||
are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. 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
|
||||
@@ -43,8 +48,17 @@ uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
Open `http://127.0.0.1:8000/` for the dashboard. To verify the backend and its
|
||||
Gitea connection directly, request
|
||||
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
|
||||
`user`, `repos`, `issues`, and `pull_requests`. Never commit the token or place
|
||||
it in a tracked configuration file.
|
||||
`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard
|
||||
to search commands plus issues and pull requests across every repository visible to
|
||||
the configured Gitea token. Remote search starts after two characters, is debounced,
|
||||
and keeps local commands usable if Gitea search is unavailable. Selecting a remote
|
||||
result opens a mobile-safe, read-only preview without discarding the search query;
|
||||
open unassigned issues can be claimed in place and handed into My Work after Gitea
|
||||
confirms the assignment. Closed work and pull requests remain read-only with a safe
|
||||
canonical Gitea link. The bounded APIs are available at
|
||||
`GET /api/v1/search?q=<query>&limit=<1-25>` and
|
||||
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`. Never commit the token
|
||||
or place it in a tracked configuration file.
|
||||
|
||||
For service monitoring, GET `/healthz` is a liveness check that confirms the
|
||||
API process is running and does not contact Gitea. GET `/readyz` is the
|
||||
|
||||
+50
-1
@@ -3,8 +3,57 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = filterCommands;
|
||||
if (root) root.filterCommands = filterCommands;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function filterCommands(commands, filter) {
|
||||
function filterCommands(commands, filter) {
|
||||
const term = String(filter || '').toLowerCase();
|
||||
return commands.filter(command => command.name.toLowerCase().includes(term));
|
||||
}
|
||||
|
||||
filterCommands.nextSelection = function nextSelection(current, key, count) {
|
||||
if (count < 1) return -1;
|
||||
if (key === 'ArrowDown') return (current + 1 + count) % count;
|
||||
if (key === 'ArrowUp') return (current - 1 + count) % count;
|
||||
return current;
|
||||
};
|
||||
|
||||
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
|
||||
const search = options.search;
|
||||
const onState = options.onState;
|
||||
const delay = options.delay === undefined ? 250 : options.delay;
|
||||
let timer = null;
|
||||
let generation = 0;
|
||||
let activeController = null;
|
||||
|
||||
return {
|
||||
setQuery(value) {
|
||||
const query = String(value || '').trim();
|
||||
generation += 1;
|
||||
const current = generation;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
if (activeController !== null) activeController.abort();
|
||||
activeController = null;
|
||||
if (query.length < 2) {
|
||||
onState({ status: 'idle', query, items: [] });
|
||||
return;
|
||||
}
|
||||
onState({ status: 'loading', query, items: [] });
|
||||
timer = setTimeout(async () => {
|
||||
const requestController = new AbortController();
|
||||
activeController = requestController;
|
||||
try {
|
||||
const result = await search(query, requestController.signal);
|
||||
const items = Array.isArray(result) ? result : result.items;
|
||||
const partial = !Array.isArray(result) && result.partial === true;
|
||||
if (current === generation) onState({ status: 'ready', query, items, partial });
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (current === generation) onState({ status: 'error', query, items: [], error });
|
||||
} finally {
|
||||
if (activeController === requestController) activeController = null;
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return filterCommands;
|
||||
});
|
||||
+560
-28
@@ -40,8 +40,11 @@ input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px;
|
||||
textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||||
#cmd-palette.open { display: block; }
|
||||
.cmd-item { padding: 10px; cursor: pointer; border-radius: 10px; color:#e5e7eb; }
|
||||
.cmd-item:hover { background: #10233a; }
|
||||
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
|
||||
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
|
||||
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
|
||||
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
||||
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
|
||||
.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
|
||||
@@ -131,6 +134,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 +149,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 +160,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; }
|
||||
@@ -157,6 +176,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
|
||||
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.search-preview { position:fixed; inset:0; z-index:60; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.search-preview.open { display:flex; }
|
||||
.search-preview-panel { box-sizing:border-box; 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; overflow-wrap:anywhere; }
|
||||
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
|
||||
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
|
||||
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.create-issue-sheet.open { display:flex; }
|
||||
@@ -178,6 +205,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; }
|
||||
@@ -191,6 +229,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
}
|
||||
@@ -219,6 +258,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>
|
||||
@@ -304,8 +344,27 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
</main>
|
||||
|
||||
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
||||
<input id="cmd-input" type="text" placeholder="Type a command, e.g. whiteboard, markdown..." />
|
||||
<div id="cmd-results" class="stack" style="margin-top:8px;"></div>
|
||||
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
|
||||
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results" style="margin-top:8px;max-height:min(65vh,520px);overflow-y:auto;"></div>
|
||||
</div>
|
||||
|
||||
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
|
||||
<section class="search-preview-panel">
|
||||
<div class="search-preview-header">
|
||||
<div>
|
||||
<div class="small" id="search-preview-key"></div>
|
||||
<h3 id="search-preview-title">Work preview</h3>
|
||||
</div>
|
||||
<button id="close-search-preview" type="button">Back to search</button>
|
||||
</div>
|
||||
<div id="search-preview-status" class="small" aria-live="polite">Choose a search result.</div>
|
||||
<div id="search-preview-meta" class="row"></div>
|
||||
<p id="search-preview-body" class="search-preview-body"></p>
|
||||
<div class="search-preview-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="whiteboard-modal">
|
||||
@@ -335,7 +394,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 +402,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 +440,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 +524,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
<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 +542,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 +556,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 +616,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>
|
||||
|
||||
@@ -523,6 +628,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
@@ -576,10 +682,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 +706,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 +778,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 +895,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 +1025,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 +1060,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 +1200,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 +1215,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 +1244,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 +1263,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 +1341,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 +1362,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#pull-sheet').classList.remove('open');
|
||||
selectedPull = null;
|
||||
selectedPullDetail = null;
|
||||
pullReviewState = null;
|
||||
if (pullTrigger?.isConnected) pullTrigger.focus();
|
||||
}
|
||||
|
||||
@@ -1561,17 +1791,183 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
{ name: 'Refresh now', run: load },
|
||||
{ name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) },
|
||||
];
|
||||
let commandSearchState = { status:'idle', query:'', items:[] };
|
||||
let commandItems = [];
|
||||
let commandSelection = -1;
|
||||
async function searchGlobalWork(query, signal) {
|
||||
const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10', {
|
||||
headers: { Accept:'application/json' },
|
||||
signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Search is temporarily unavailable.');
|
||||
return {
|
||||
items: Array.isArray(payload.items) ? payload.items : [],
|
||||
partial: payload.partial === true,
|
||||
};
|
||||
}
|
||||
const commandSearch = filterCommands.createGlobalSearchController({
|
||||
search: searchGlobalWork,
|
||||
onState: state => {
|
||||
commandSearchState = state;
|
||||
renderCommands(state.query);
|
||||
},
|
||||
});
|
||||
function safeSearchUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||||
} catch (_) { return '';
|
||||
}
|
||||
}
|
||||
let searchPreviewDetail = null;
|
||||
function searchPreviewPath(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview?kind=' + encodeURIComponent(item.kind);
|
||||
}
|
||||
function renderSearchPreview(state) {
|
||||
const sheet = qs('#search-preview');
|
||||
const status = qs('#search-preview-status');
|
||||
const claimButton = qs('#claim-search-result');
|
||||
if (state.status === 'closed') {
|
||||
sheet.classList.remove('open');
|
||||
return;
|
||||
}
|
||||
sheet.classList.add('open');
|
||||
claimButton.hidden = true;
|
||||
claimButton.disabled = false;
|
||||
if (state.status === 'loading') {
|
||||
searchPreviewDetail = null;
|
||||
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
|
||||
qs('#search-preview-title').textContent = state.item.title || 'Work preview';
|
||||
qs('#search-preview-meta').textContent = '';
|
||||
qs('#search-preview-body').textContent = '';
|
||||
qs('#open-search-result-gitea').href = safeSearchUrl(state.item.url) || '#';
|
||||
status.textContent = 'Loading preview…';
|
||||
return;
|
||||
}
|
||||
if (state.status === 'error') {
|
||||
status.textContent = state.error?.message || 'Preview unavailable. Open this result in Gitea or retry.';
|
||||
return;
|
||||
}
|
||||
const detail = state.detail;
|
||||
if (!detail) return;
|
||||
searchPreviewDetail = detail;
|
||||
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
|
||||
qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
|
||||
qs('#search-preview-meta').textContent =
|
||||
(detail.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + (detail.state || 'unknown') +
|
||||
(detail.author ? ' · by ' + detail.author : '') +
|
||||
(detail.labels?.length ? ' · ' + detail.labels.join(', ') : '') +
|
||||
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
|
||||
qs('#search-preview-body').textContent = detail.body || 'No description provided.';
|
||||
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
|
||||
claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue'));
|
||||
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
|
||||
claimButton.disabled = state.status === 'claiming';
|
||||
status.textContent = state.status === 'claiming' ? 'Assigning this issue to you…' :
|
||||
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
|
||||
(detail.claimable ? 'This issue is open and unassigned.' :
|
||||
(detail.assigned_to_me ? 'This issue is already in My Work.' : 'Read-only preview.')));
|
||||
}
|
||||
const searchPreview = createSearchPreview({
|
||||
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
|
||||
claim: detail => fetchReviewJson(
|
||||
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(detail.number) + '/claim',
|
||||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||||
),
|
||||
onState: renderSearchPreview,
|
||||
});
|
||||
function closeSearchPreview() {
|
||||
searchPreview.close();
|
||||
qs('#cmd-palette').classList.add('open');
|
||||
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
||||
renderCommands(qs('#cmd-input').value);
|
||||
qs('#cmd-input').focus();
|
||||
}
|
||||
async function openPreviewIssueInMyWork(detail) {
|
||||
await load();
|
||||
const item = lastMyWork.find(candidate =>
|
||||
candidate.kind === 'issue' && candidate.key === detail.repository + '#' + detail.number
|
||||
);
|
||||
if (!item) return false;
|
||||
searchPreview.close();
|
||||
await openIssueSheet(item, qs('#find-work'));
|
||||
return true;
|
||||
}
|
||||
function runCommandItem(item) {
|
||||
if (item.command) {
|
||||
item.command.run();
|
||||
qs('#cmd-input').value = '';
|
||||
} else {
|
||||
searchPreview.open(item.result).catch(() => {});
|
||||
}
|
||||
qs('#cmd-palette').classList.remove('open');
|
||||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
function renderCommands(filter) {
|
||||
const el = qs('#cmd-results');
|
||||
const items = filterCommands(commands, filter);
|
||||
el.innerHTML = items.map((c, idx) => '<div class="cmd-item" data-idx="' + idx + '">' + escapeHtml(c.name) + '</div>').join('');
|
||||
const local = filterCommands(commands, filter).map(command => ({ command }));
|
||||
const remote = commandSearchState.query === String(filter || '').trim()
|
||||
? commandSearchState.items.map(result => ({ result })) : [];
|
||||
commandItems = local.concat(remote);
|
||||
if (commandSelection >= commandItems.length) commandSelection = -1;
|
||||
let html = local.length ? '<div class="cmd-group">Commands</div>' : '';
|
||||
html += local.map((item, idx) => '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(item.command.name) + '</span><span class="cmd-meta">Command</span></div>').join('');
|
||||
if (remote.length) html += '<div class="cmd-group">Issues and pull requests</div>';
|
||||
html += remote.map((item, remoteIdx) => {
|
||||
const idx = local.length + remoteIdx;
|
||||
const result = item.result;
|
||||
return '<div class="cmd-item' + (idx === commandSelection ? ' selected' : '') + '" role="option" aria-selected="' + (idx === commandSelection) + '" data-idx="' + idx + '"><span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' + escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' + escapeHtml(result.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + escapeHtml(result.state) + '</span></div>';
|
||||
}).join('');
|
||||
if (commandSearchState.status === 'loading') html += '<div class="cmd-status">Searching accessible work…</div>';
|
||||
else if (commandSearchState.status === 'error') html += '<div class="cmd-status">Search unavailable. Keep typing or retry.</div>';
|
||||
else if (commandSearchState.partial) html += '<div class="cmd-status">Some results are temporarily unavailable.</div>';
|
||||
else if (String(filter || '').trim().length >= 2 && !remote.length) html += '<div class="cmd-status">No matching issues or pull requests.</div>';
|
||||
el.innerHTML = html;
|
||||
el.querySelectorAll('.cmd-item').forEach((item) => {
|
||||
item.addEventListener('click', () => { items[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value=''; });
|
||||
item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)]));
|
||||
});
|
||||
}
|
||||
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
|
||||
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
|
||||
function openCommandPalette() {
|
||||
qs('#cmd-palette').classList.add('open');
|
||||
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
||||
qs('#cmd-input').focus();
|
||||
commandSelection = -1;
|
||||
renderCommands(qs('#cmd-input').value);
|
||||
}
|
||||
qs('#open-palette').addEventListener('click', openCommandPalette);
|
||||
qs('#cmd-input').addEventListener('input', (e) => {
|
||||
commandSelection = -1;
|
||||
renderCommands(e.target.value);
|
||||
commandSearch.setQuery(e.target.value);
|
||||
});
|
||||
qs('#cmd-input').addEventListener('keydown', event => {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
commandSelection = filterCommands.nextSelection(commandSelection, event.key, commandItems.length);
|
||||
renderCommands(event.currentTarget.value);
|
||||
const selected = qs('#cmd-results .selected');
|
||||
if (selected) selected.scrollIntoView({ block:'nearest' });
|
||||
} else if (event.key === 'Enter' && commandSelection >= 0) {
|
||||
event.preventDefault();
|
||||
runCommandItem(commandItems[commandSelection]);
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && qs('#cmd-palette').classList.contains('open')) {
|
||||
e.preventDefault();
|
||||
qs('#cmd-palette').classList.remove('open');
|
||||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && qs('#search-preview').classList.contains('open')) {
|
||||
e.preventDefault();
|
||||
closeSearchPreview();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && findingWork) {
|
||||
e.preventDefault();
|
||||
closeFindWorkSheet();
|
||||
@@ -1595,11 +1991,25 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
|
||||
e.preventDefault();
|
||||
qs('#cmd-palette').classList.toggle('open');
|
||||
if (qs('#cmd-palette').classList.contains('open')) {
|
||||
qs('#cmd-input').focus();
|
||||
renderCommands('');
|
||||
qs('#cmd-palette').classList.remove('open');
|
||||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||||
} else openCommandPalette();
|
||||
}
|
||||
});
|
||||
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
|
||||
qs('#claim-search-result').addEventListener('click', async () => {
|
||||
if (!searchPreviewDetail || (!searchPreviewDetail.claimable && !searchPreviewDetail.assigned_to_me)) return;
|
||||
try {
|
||||
const claimed = searchPreviewDetail;
|
||||
if (claimed.claimable) await searchPreview.claim(claimed);
|
||||
const opened = await openPreviewIssueInMyWork(claimed);
|
||||
if (!opened) {
|
||||
qs('#search-preview-status').textContent =
|
||||
'Assignment confirmed. Refresh My Work to open the issue.';
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
|
||||
}
|
||||
});
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
@@ -1668,6 +2078,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 +2172,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 +2247,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 +2265,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 +2281,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 +2317,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 +2422,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 +2483,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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
(function (root, factory) {
|
||||
const createSearchPreview = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
||||
if (root) root.createSearchPreview = createSearchPreview;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, claim, onState }) {
|
||||
let generation = 0;
|
||||
let current = null;
|
||||
let claimRequest = null;
|
||||
|
||||
return {
|
||||
open(item) {
|
||||
generation += 1;
|
||||
const requestGeneration = generation;
|
||||
current = { ...item };
|
||||
onState({ status: 'loading', item: current });
|
||||
return fetchJson(current).then(detail => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'ready', item: current, detail });
|
||||
}
|
||||
return detail;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'error', item: current, error });
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
close() {
|
||||
generation += 1;
|
||||
current = null;
|
||||
onState({ status: 'closed' });
|
||||
},
|
||||
claim(detail) {
|
||||
if (claimRequest) return claimRequest;
|
||||
onState({ status: 'claiming', item: current, detail });
|
||||
claimRequest = claim(detail).then(result => {
|
||||
onState({ status: 'claimed', item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { claimRequest = null; });
|
||||
return claimRequest;
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
+203
-1
@@ -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:
|
||||
@@ -179,6 +183,112 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
repository = item.get("repository")
|
||||
repository = repository if isinstance(repository, dict) else {}
|
||||
url = _safe_web_url(item.get("html_url"))
|
||||
if not (
|
||||
isinstance(item.get("number"), int)
|
||||
and isinstance(item.get("title"), str)
|
||||
and isinstance(item.get("state"), str)
|
||||
and isinstance(repository.get("full_name"), str)
|
||||
and repository.get("full_name")
|
||||
and url
|
||||
):
|
||||
return None
|
||||
return {
|
||||
"kind": kind,
|
||||
"repository": repository["full_name"],
|
||||
"number": item["number"],
|
||||
"title": item["title"],
|
||||
"state": item["state"],
|
||||
"url": url,
|
||||
}
|
||||
|
||||
|
||||
async def global_search(query: str, limit: int = 10) -> dict:
|
||||
"""Search accessible issues and pulls concurrently with a bounded result set."""
|
||||
async def load(item_type: str) -> Any:
|
||||
response = await _get_client().get(
|
||||
"/api/v1/repos/issues/search",
|
||||
headers=_auth(),
|
||||
params={"q": query, "type": item_type, "limit": limit, "page": 1},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea global search response was not a list")
|
||||
return payload
|
||||
|
||||
outcomes = await asyncio.gather(
|
||||
load("issues"), load("pulls"), return_exceptions=True
|
||||
)
|
||||
for outcome in outcomes:
|
||||
if isinstance(outcome, asyncio.CancelledError):
|
||||
raise outcome
|
||||
if all(isinstance(outcome, BaseException) for outcome in outcomes):
|
||||
raise outcomes[0]
|
||||
|
||||
results = []
|
||||
seen: set[tuple[str, str, int]] = set()
|
||||
for outcome, kind in zip(outcomes, ("issue", "pull"), strict=True):
|
||||
if isinstance(outcome, BaseException):
|
||||
continue
|
||||
for item in outcome:
|
||||
normalized = _normalize_global_search_item(item, kind)
|
||||
if normalized is not None:
|
||||
identity = (kind, normalized["repository"], normalized["number"])
|
||||
if identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
results.append(normalized)
|
||||
return {
|
||||
"items": results[:limit],
|
||||
"partial": any(isinstance(outcome, BaseException) for outcome in outcomes),
|
||||
}
|
||||
|
||||
|
||||
async def work_preview(repository: str, kind: str, number: int) -> dict:
|
||||
"""Load bounded, read-only context for a global search result."""
|
||||
issue, user = await asyncio.gather(
|
||||
fetch(f"repos/{repository}/issues/{number}"), current_user()
|
||||
)
|
||||
if not isinstance(issue, dict):
|
||||
raise ValueError("Gitea work preview response was not an object")
|
||||
labels_value = issue.get("labels")
|
||||
assignees_value = issue.get("assignees")
|
||||
labels = labels_value if isinstance(labels_value, list) else []
|
||||
assignees = assignees_value if isinstance(assignees_value, list) else []
|
||||
assignee_names = [
|
||||
assignee["login"] for assignee in assignees
|
||||
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
|
||||
]
|
||||
author = issue.get("user")
|
||||
author = author if isinstance(author, dict) else {}
|
||||
login = user.get("login") if isinstance(user, dict) else ""
|
||||
state = issue.get("state") if isinstance(issue.get("state"), str) else ""
|
||||
actual_kind = "pull" if isinstance(issue.get("pull_request"), dict) else "issue"
|
||||
return {
|
||||
"kind": actual_kind,
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
|
||||
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
|
||||
"state": state,
|
||||
"author": author.get("login", "") if isinstance(author.get("login"), str) else "",
|
||||
"labels": [
|
||||
label["name"] for label in labels
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||
],
|
||||
"assignees": assignee_names,
|
||||
"url": _safe_web_url(issue.get("html_url")),
|
||||
"claimable": actual_kind == "issue" and state == "open" and not assignee_names,
|
||||
"assigned_to_me": bool(login and login in assignee_names),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_available_issue(item: Any) -> dict | None:
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
@@ -728,6 +838,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 +894,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 +909,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 +1030,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 +1060,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)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reservation:
|
||||
state: str
|
||||
response: Any = None
|
||||
|
||||
|
||||
class IdempotencyLedger:
|
||||
"""Small durable ledger for replaying successful Gitea mutations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
ttl_seconds: float,
|
||||
max_entries: int,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.max_entries = max_entries
|
||||
self.clock = clock
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=5)
|
||||
connection.execute("PRAGMA busy_timeout = 5000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS idempotency_operations (
|
||||
key TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending', 'completed')),
|
||||
response_json TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
completed_at REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idempotency_completed_at_idx "
|
||||
"ON idempotency_operations(completed_at)"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _fingerprint(value: tuple[Any, ...]) -> str:
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
def reserve(self, key: str, fingerprint: tuple[Any, ...]) -> Reservation:
|
||||
encoded = self._fingerprint(fingerprint)
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"DELETE FROM idempotency_operations "
|
||||
"WHERE status = 'completed' AND completed_at <= ?",
|
||||
(now - self.ttl_seconds,),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT fingerprint, status, response_json FROM idempotency_operations "
|
||||
"WHERE key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row[0] != encoded:
|
||||
return Reservation("conflict")
|
||||
if row[1] == "completed":
|
||||
return Reservation("completed", json.loads(row[2]))
|
||||
return Reservation("pending")
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) FROM idempotency_operations"
|
||||
).fetchone()[0]
|
||||
if count >= self.max_entries:
|
||||
completed = connection.execute(
|
||||
"SELECT key FROM idempotency_operations "
|
||||
"WHERE status = 'completed' ORDER BY completed_at, rowid LIMIT 1"
|
||||
).fetchone()
|
||||
if completed is None:
|
||||
return Reservation("busy")
|
||||
connection.execute(
|
||||
"DELETE FROM idempotency_operations WHERE key = ?",
|
||||
(completed[0],),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency_operations "
|
||||
"(key, fingerprint, status, created_at) VALUES (?, ?, 'pending', ?)",
|
||||
(key, encoded, now),
|
||||
)
|
||||
return Reservation("reserved")
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM idempotency_operations")
|
||||
|
||||
def complete(self, key: str, response: Any) -> None:
|
||||
encoded = json.dumps(response, separators=(",", ":"), sort_keys=True)
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET status = 'completed', "
|
||||
"response_json = ?, completed_at = ? WHERE key = ?",
|
||||
(encoded, self.clock(), key),
|
||||
)
|
||||
+273
-56
@@ -1,7 +1,8 @@
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
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
|
||||
@@ -24,6 +25,7 @@ from src.gitea_proxy import (
|
||||
pull_review_detail,
|
||||
repos,
|
||||
)
|
||||
from src.idempotency import IdempotencyLedger
|
||||
from src.models import Issue, PullRequest, Repo, User
|
||||
from src.suggestion_engine import compute
|
||||
from src.views import router as frontend_router
|
||||
@@ -61,9 +63,12 @@ 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
|
||||
GLOBAL_SEARCH_TIMEOUT_SECONDS = 3.0
|
||||
NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0
|
||||
BULK_NOTIFICATION_CONCURRENCY = 5
|
||||
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
|
||||
@@ -87,9 +92,18 @@ _live_section_retry_at: dict[str, float | None] = {
|
||||
}
|
||||
_live_snapshot_refreshing_sections: set[str] = set()
|
||||
_read_notification_ids: set[int] = set()
|
||||
_issue_creation_operations: dict[
|
||||
_authored_action_operations: dict[
|
||||
str, tuple[tuple[Any, ...], asyncio.Task, float]
|
||||
] = {}
|
||||
_state_dir = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"))
|
||||
_idempotency_ledger = IdempotencyLedger(
|
||||
os.getenv("STACKCHAIN_IDEMPOTENCY_DB", str(_state_dir / "idempotency.sqlite3")),
|
||||
ttl_seconds=AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS,
|
||||
max_entries=(
|
||||
AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES
|
||||
+ ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES
|
||||
),
|
||||
)
|
||||
_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 +164,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 +234,65 @@ 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)
|
||||
|
||||
reservation = _idempotency_ledger.reserve(idempotency_key, fingerprint)
|
||||
if reservation.state == "conflict":
|
||||
operation.close()
|
||||
raise HTTPException(status_code=409, detail="Idempotency key already used")
|
||||
if reservation.state == "completed":
|
||||
operation.close()
|
||||
return reservation.response
|
||||
if reservation.state == "busy":
|
||||
operation.close()
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Authored action queue is busy; please retry",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
existing = _authored_action_operations.get(idempotency_key)
|
||||
if reservation.state == "pending":
|
||||
operation.close()
|
||||
if existing is None or existing[0] != fingerprint:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"This action may still be completing; verify its result before retrying"
|
||||
),
|
||||
headers={"Retry-After": "5"},
|
||||
)
|
||||
task = existing[1]
|
||||
else:
|
||||
async def persist_result():
|
||||
result = await operation
|
||||
_idempotency_ledger.complete(idempotency_key, result)
|
||||
return result
|
||||
|
||||
task = asyncio.create_task(persist_result())
|
||||
_authored_action_operations[idempotency_key] = (
|
||||
fingerprint,
|
||||
task,
|
||||
time.monotonic(),
|
||||
)
|
||||
|
||||
def discard_completed(completed: asyncio.Task) -> None:
|
||||
existing_operation = _authored_action_operations.get(idempotency_key)
|
||||
if existing_operation is not None and existing_operation[1] is completed:
|
||||
_authored_action_operations.pop(idempotency_key, None)
|
||||
|
||||
task.add_done_callback(discard_completed)
|
||||
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 +316,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 +356,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"))
|
||||
@@ -282,7 +386,7 @@ app.include_router(frontend_router)
|
||||
@app.middleware("http")
|
||||
async def prevent_live_api_caching(request, call_next):
|
||||
response = await call_next(request)
|
||||
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues"} or request.url.path.startswith("/api/v1/work/") or (
|
||||
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search"} or request.url.path.startswith("/api/v1/work/") or (
|
||||
request.url.path.startswith("/api/v1/repos/")
|
||||
and request.url.path.endswith("/review")
|
||||
) or request.url.path.startswith("/api/v1/notifications") or (
|
||||
@@ -389,6 +493,49 @@ async def context() -> JSONResponse:
|
||||
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
|
||||
|
||||
|
||||
@app.get("/api/v1/search")
|
||||
async def global_search(
|
||||
q: str = Query(min_length=2, max_length=100),
|
||||
limit: int = Query(default=10, ge=1, le=25),
|
||||
) -> JSONResponse:
|
||||
query = q.strip()
|
||||
if len(query) < 2:
|
||||
raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters")
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.global_search(query, limit),
|
||||
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Search is temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse({"query": query, **result})
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview")
|
||||
async def global_search_preview(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
kind: Literal["issue", "pull"] = Query(),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
preview = await asyncio.wait_for(
|
||||
gitea_proxy.work_preview(f"{owner}/{repo}", kind, number),
|
||||
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "This work item is temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(preview)
|
||||
|
||||
|
||||
@app.get("/api/v1/work/{stream}")
|
||||
async def paged_work(
|
||||
stream: Literal["issue", "pull", "review"],
|
||||
@@ -908,13 +1055,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 +1166,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}"
|
||||
@@ -1074,48 +1290,19 @@ async def create_assigned_issue(
|
||||
repository, creation.title, creation.body, login, creation.label_ids
|
||||
)
|
||||
|
||||
operation = create_issue()
|
||||
if idempotency_key:
|
||||
now = time.monotonic()
|
||||
expired = [
|
||||
key for key, (_, task, created_at) in _issue_creation_operations.items()
|
||||
if task.done()
|
||||
and now - created_at >= ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS
|
||||
]
|
||||
for key in expired:
|
||||
_issue_creation_operations.pop(key, None)
|
||||
fingerprint = (
|
||||
repository, creation.title, creation.body, tuple(creation.label_ids)
|
||||
)
|
||||
existing = _issue_creation_operations.get(idempotency_key)
|
||||
if existing is not None:
|
||||
operation.close()
|
||||
if existing[0] != fingerprint:
|
||||
raise HTTPException(status_code=409, detail="Idempotency key already used")
|
||||
task = existing[1]
|
||||
else:
|
||||
while len(_issue_creation_operations) >= ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES:
|
||||
completed = [
|
||||
key for key, (_, task, _) in _issue_creation_operations.items()
|
||||
if task.done()
|
||||
]
|
||||
if not completed:
|
||||
operation.close()
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Issue creation is busy; please retry",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
oldest = min(
|
||||
completed, key=lambda key: _issue_creation_operations[key][2]
|
||||
)
|
||||
_issue_creation_operations.pop(oldest)
|
||||
task = asyncio.create_task(operation)
|
||||
_issue_creation_operations[idempotency_key] = (fingerprint, task, now)
|
||||
operation = asyncio.shield(task)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(operation, timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||
result = await _run_idempotent_authored_action(
|
||||
create_issue(),
|
||||
idempotency_key=idempotency_key,
|
||||
fingerprint=(
|
||||
"issue-create",
|
||||
repository,
|
||||
creation.title,
|
||||
creation.body,
|
||||
tuple(creation.label_ids),
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
@@ -1129,7 +1316,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 +1330,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 +1488,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 +1502,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 +1561,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 +1587,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
|
||||
|
||||
@@ -26,6 +26,7 @@ class Issue(BaseModel):
|
||||
assignees: list[str] = []
|
||||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
due_date: str | None = None
|
||||
url: str
|
||||
|
||||
|
||||
|
||||
@@ -19,5 +19,6 @@ def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/work/",
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_authored_operations():
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
yield
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.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")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_completed_comment_replays_after_ledger_reconstruction(monkeypatch, tmp_path):
|
||||
calls = 0
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": 201, "body": body}
|
||||
|
||||
database = tmp_path / "actions.sqlite3"
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_idempotency_ledger",
|
||||
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||
raising=False,
|
||||
)
|
||||
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": "restart-comment-201"}
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Persist me"},
|
||||
headers=headers,
|
||||
)
|
||||
main._authored_action_operations.clear()
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_idempotency_ledger",
|
||||
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||
)
|
||||
replayed = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Persist me"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert [created.status_code, replayed.status_code] == [201, 201]
|
||||
assert replayed.json() == {"id": 201, "body": "Persist me"}
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orphaned_pending_comment_fails_closed_without_upstream_retry(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
calls = 0
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": 202, "body": body}
|
||||
|
||||
ledger = IdempotencyLedger(
|
||||
tmp_path / "orphan.sqlite3", ttl_seconds=600, max_entries=256
|
||||
)
|
||||
fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first")
|
||||
assert ledger.reserve("orphan-comment-201", fingerprint).state == "reserved"
|
||||
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Verify first"},
|
||||
headers={"Idempotency-Key": "orphan-comment-201"},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == (
|
||||
"This action may still be completing; verify its result before retrying"
|
||||
)
|
||||
assert calls == 0
|
||||
@@ -7,6 +7,7 @@ from urllib.parse import urljoin
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
COMMANDS = FRONTEND / "commands.js"
|
||||
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
@@ -46,3 +47,194 @@ def test_command_script_resolves_inside_dashboard_subpath():
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/", command_source
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
|
||||
|
||||
|
||||
def test_remote_command_search_ignores_stale_responses():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const pending = new Map();
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: query => new Promise(resolve => pending.set(query, resolve)),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setQuery('release');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get('release')([{{ title: 'Current result' }}]);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get('mobile')([{{ title: 'Stale result' }}]);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.filter(state => state.status === 'ready');
|
||||
if (ready.length !== 1) throw new Error(`expected one ready state, got ${{ready.length}}`);
|
||||
if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result') {{
|
||||
throw new Error(`stale response replaced current state: ${{JSON.stringify(ready)}}`);
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_remote_command_search_aborts_superseded_and_cleared_queries():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const signals = [];
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: (query, signal) => {{
|
||||
signals.push(signal);
|
||||
return new Promise((resolve, reject) => signal.addEventListener('abort', () => {{
|
||||
const error = new Error('aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
}}));
|
||||
}},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setQuery('release');
|
||||
if (!signals[0].aborted) throw new Error('superseded request was not aborted');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
controller.setQuery('');
|
||||
if (!signals[1].aborted) throw new Error('cleared query request was not aborted');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (states.some(state => state.status === 'error')) throw new Error('abort rendered an error');
|
||||
if (states.at(-1).status !== 'idle') throw new Error('clear did not restore idle state');
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_command_search_preserves_partial_result_status():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
(async () => {{
|
||||
const states = [];
|
||||
const controller = filterCommands.createGlobalSearchController({{
|
||||
delay: 0,
|
||||
search: () => Promise.resolve({{ items:[{{ title:'Useful result' }}], partial:true }}),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.setQuery('mobile');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.find(state => state.status === 'ready');
|
||||
if (!ready || ready.items.length !== 1 || ready.partial !== true) {{
|
||||
throw new Error('partial result metadata was lost: ' + JSON.stringify(states));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_command_selection_wraps_for_arrow_keys():
|
||||
script = f"""
|
||||
const commands = require({json.dumps(str(COMMANDS))});
|
||||
if (commands.nextSelection(-1, 'ArrowDown', 3) !== 0) throw new Error('should select first');
|
||||
if (commands.nextSelection(2, 'ArrowDown', 3) !== 0) throw new Error('should wrap down');
|
||||
if (commands.nextSelection(0, 'ArrowUp', 3) !== 2) throw new Error('should wrap up');
|
||||
if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys should not move');
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
|
||||
assert 'role="combobox"' in html
|
||||
assert 'aria-controls="cmd-results"' in html
|
||||
assert 'role="listbox"' in html
|
||||
assert "fetch('api/v1/search?q='" in html
|
||||
assert "async function searchGlobalWork(query, signal)" in html
|
||||
assert "signal," in html
|
||||
assert "Some results are temporarily unavailable." in html
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/",
|
||||
"api/v1/search?q=mobile",
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile"
|
||||
|
||||
|
||||
def test_search_preview_ignores_stale_result_details():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const pending = new Map();
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)),
|
||||
claim: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }});
|
||||
preview.open({{ repository:'stackchain/api', number:2, kind:'issue' }});
|
||||
pending.get(2)({{ number:2, title:'Current' }});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
pending.get(1)({{ number:1, title:'Stale' }});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const ready = states.filter(state => state.status === 'ready');
|
||||
if (ready.length !== 1 || ready[0].detail.number !== 2) {{
|
||||
throw new Error('stale detail replaced current preview: ' + JSON.stringify(ready));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_claim_is_single_flight_and_reports_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let claims = 0;
|
||||
let resolveClaim;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item),
|
||||
claim: () => {{ claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
|
||||
const detail = {{ repository:'stackchain/api', number:42, claimable:true }};
|
||||
const first = preview.claim(detail);
|
||||
const second = preview.claim(detail);
|
||||
if (claims !== 1 || first !== second) throw new Error('claim was not single-flight');
|
||||
resolveClaim({{ assignees:['timmy'] }});
|
||||
await first;
|
||||
if (!states.some(state => state.status === 'claimed')) throw new Error('claim confirmation missing');
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
|
||||
run_item = html.split("function runCommandItem(item)", 1)[1].split(
|
||||
"function renderCommands", 1
|
||||
)[0]
|
||||
assert "searchPreview.open(item.result)" in run_item
|
||||
assert "window.location.assign" not in run_item
|
||||
remote_branch = run_item.split("} else {", 1)[1]
|
||||
assert "qs('#cmd-input').value = ''" not in remote_branch
|
||||
|
||||
|
||||
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
|
||||
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
|
||||
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
|
||||
assert "openPreviewIssueInMyWork" in html
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def search(query, limit):
|
||||
requested.append((query, limit))
|
||||
return {
|
||||
"items": [{
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Repair mobile queue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}],
|
||||
"partial": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "global_search", search, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/search?q=mobile&limit=7")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert requested == [("mobile", 7)]
|
||||
assert response.json() == {"query": "mobile", "items": [{
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Repair mobile queue",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}], "partial": False}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_returns_normalized_action_context(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
requested.append((repository, kind, number))
|
||||
return {
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Repair mobile queue",
|
||||
"body": "Restore the queue before release.",
|
||||
"state": "open",
|
||||
"author": "alex",
|
||||
"labels": ["P1"],
|
||||
"assignees": [],
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
"assigned_to_me": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview?kind=issue"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert requested == [("stackchain/api", "issue", 42)]
|
||||
assert response.json()["claimable"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(dict(request.url.params))
|
||||
kind = request.url.params["type"]
|
||||
if kind == "issues":
|
||||
return httpx.Response(200, json=[{
|
||||
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
}, {
|
||||
"id": 4, "number": 42, "title": "Repair queue", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
}, {
|
||||
"id": 5, "number": 43, "title": "Unsafe", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "javascript:alert(1)",
|
||||
}])
|
||||
return httpx.Response(200, json=[{
|
||||
"id": 8, "number": 9, "title": "Improve search", "state": "closed",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
results = await gitea_proxy.global_search("mobile queue", 7)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert {request["type"] for request in requests} == {"issues", "pulls"}
|
||||
assert all(request["q"] == "mobile queue" and request["limit"] == "7" for request in requests)
|
||||
assert results == {
|
||||
"items": [{
|
||||
"kind": "issue", "repository": "stackchain/api", "number": 42,
|
||||
"title": "Repair queue", "state": "open",
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
}, {
|
||||
"kind": "pull", "repository": "stackchain/web", "number": 9,
|
||||
"title": "Improve search", "state": "closed",
|
||||
"url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}],
|
||||
"partial": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_returns_healthy_stream_when_other_stream_fails():
|
||||
async def handler(request):
|
||||
if request.url.params["type"] == "issues":
|
||||
return httpx.Response(503, json={"message": "upstream details must stay private"})
|
||||
return httpx.Response(200, json=[{
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.global_search("search", 10)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"items": [{
|
||||
"kind": "pull",
|
||||
"repository": "stackchain/web",
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/web/pulls/9",
|
||||
}],
|
||||
"partial": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_caps_combined_results_to_requested_limit():
|
||||
async def handler(request):
|
||||
kind = request.url.params["type"]
|
||||
items = [{
|
||||
"number": number,
|
||||
"title": f"{kind} {number}",
|
||||
"state": "open",
|
||||
"repository": {"full_name": "stackchain/web"},
|
||||
"html_url": f"https://forge.example/stackchain/web/{kind}/{number}",
|
||||
} for number in (1, 2)]
|
||||
return httpx.Response(200, json=items)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.global_search("search", 2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(result["items"]) == 2
|
||||
assert result["partial"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_propagates_cancellation_to_stop_obsolete_work():
|
||||
async def handler(request):
|
||||
if request.url.params["type"] == "issues":
|
||||
raise asyncio.CancelledError()
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await gitea_proxy.global_search("obsolete", 10)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_issues():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 42,
|
||||
"title": "Repair queue",
|
||||
"body": "Keep mobile operators moving.",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/42",
|
||||
"user": {"login": "alex"},
|
||||
"labels": [{"name": "P1"}, None],
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/api", "issue", 42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview == {
|
||||
"kind": "issue",
|
||||
"repository": "stackchain/api",
|
||||
"number": 42,
|
||||
"title": "Repair queue",
|
||||
"body": "Keep mobile operators moving.",
|
||||
"state": "open",
|
||||
"author": "alex",
|
||||
"labels": ["P1"],
|
||||
"assignees": [],
|
||||
"url": "https://forge.example/stackchain/api/issues/42",
|
||||
"claimable": True,
|
||||
"assigned_to_me": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_preview_derives_pull_kind_and_never_offers_issue_claim():
|
||||
async def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"number": 9,
|
||||
"title": "Improve search",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/web/pulls/9",
|
||||
"pull_request": {"merged": False},
|
||||
"assignees": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
preview = await gitea_proxy.work_preview("stackchain/web", "issue", 9)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert preview["kind"] == "pull"
|
||||
assert preview["claimable"] is False
|
||||
@@ -0,0 +1,73 @@
|
||||
import sqlite3
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
def test_completed_result_replays_after_ledger_reconstruction(tmp_path):
|
||||
database = tmp_path / "idempotency.sqlite3"
|
||||
first = IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
|
||||
|
||||
reservation = first.reserve("create-201", ("issue-create", "stackchain/api", "Ship it"))
|
||||
assert reservation.state == "reserved"
|
||||
first.complete("create-201", {"number": 42, "title": "Ship it"})
|
||||
|
||||
reconstructed = IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
|
||||
replay = reconstructed.reserve(
|
||||
"create-201", ("issue-create", "stackchain/api", "Ship it")
|
||||
)
|
||||
|
||||
assert replay.state == "completed"
|
||||
assert replay.response == {"number": 42, "title": "Ship it"}
|
||||
assert sqlite3.connect(database).execute(
|
||||
"SELECT status FROM idempotency_operations WHERE key = ?", ("create-201",)
|
||||
).fetchone() == ("completed",)
|
||||
|
||||
|
||||
def test_reservation_expires_completed_rows_and_enforces_capacity(tmp_path):
|
||||
now = [1_000.0]
|
||||
ledger = IdempotencyLedger(
|
||||
tmp_path / "bounded.sqlite3",
|
||||
ttl_seconds=10,
|
||||
max_entries=2,
|
||||
clock=lambda: now[0],
|
||||
)
|
||||
assert ledger.reserve("old", ("comment", "old")).state == "reserved"
|
||||
ledger.complete("old", {"id": 1})
|
||||
now[0] += 11
|
||||
|
||||
assert ledger.reserve("new-1", ("comment", "new-1")).state == "reserved"
|
||||
ledger.complete("new-1", {"id": 2})
|
||||
assert ledger.reserve("new-2", ("comment", "new-2")).state == "reserved"
|
||||
ledger.complete("new-2", {"id": 3})
|
||||
replacement = ledger.reserve("new-3", ("comment", "new-3"))
|
||||
|
||||
assert replacement.state == "reserved"
|
||||
with sqlite3.connect(ledger.path) as connection:
|
||||
keys = connection.execute(
|
||||
"SELECT key FROM idempotency_operations ORDER BY key"
|
||||
).fetchall()
|
||||
indexes = connection.execute(
|
||||
"PRAGMA index_list(idempotency_operations)"
|
||||
).fetchall()
|
||||
assert keys == [("new-2",), ("new-3",)]
|
||||
assert any(index[1] == "idempotency_completed_at_idx" for index in indexes)
|
||||
|
||||
|
||||
def test_independent_connections_atomically_reserve_one_operation(tmp_path):
|
||||
database = tmp_path / "workers.sqlite3"
|
||||
ledgers = [
|
||||
IdempotencyLedger(database, ttl_seconds=600, max_entries=256)
|
||||
for _ in range(2)
|
||||
]
|
||||
barrier = Barrier(2)
|
||||
|
||||
def reserve(ledger):
|
||||
barrier.wait()
|
||||
return ledger.reserve("worker-key-201", ("review", "stackchain/api", 7)).state
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
states = list(executor.map(reserve, ledgers))
|
||||
|
||||
assert sorted(states) == ["pending", "reserved"]
|
||||
+293
-6
@@ -1,16 +1,237 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
@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()
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
yield
|
||||
main._issue_creation_operations.clear()
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -113,6 +334,61 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_completed_issue_creation_replays_after_ledger_reconstruction(monkeypatch, tmp_path):
|
||||
calls = 0
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def available_repos():
|
||||
return [{"full_name": "stackchain/api"}]
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {
|
||||
"id": 201, "number": 201, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-07T15:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/201",
|
||||
}
|
||||
|
||||
database = tmp_path / "issues.sqlite3"
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_idempotency_ledger",
|
||||
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "restart-create-201"}
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues",
|
||||
json={"title": "Durable capture"},
|
||||
headers=headers,
|
||||
)
|
||||
main._authored_action_operations.clear()
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_idempotency_ledger",
|
||||
IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
|
||||
)
|
||||
replayed = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues",
|
||||
json={"title": "Durable capture"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert [created.status_code, replayed.status_code] == [201, 201]
|
||||
assert replayed.json()["number"] == 201
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_key(monkeypatch):
|
||||
calls = []
|
||||
@@ -194,7 +470,9 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_limit(monkeypatch):
|
||||
async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
@@ -209,7 +487,10 @@ async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_lim
|
||||
"url": "https://forge.example/stackchain/api/issues/17",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "ISSUE_CREATION_IDEMPOTENCY_MAX_ENTRIES", 2)
|
||||
ledger = IdempotencyLedger(
|
||||
tmp_path / "bounded-issues.sqlite3", ttl_seconds=600, max_entries=2
|
||||
)
|
||||
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
@@ -223,8 +504,11 @@ async def test_create_issue_idempotency_registry_evicts_oldest_entry_at_size_lim
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
assert len(main._issue_creation_operations) == 2
|
||||
assert "capture-177-bounded-0" not in main._issue_creation_operations
|
||||
replay = ledger.reserve(
|
||||
"capture-177-bounded-0",
|
||||
("issue-create", "stackchain/api", "Capture 0", "", ()),
|
||||
)
|
||||
assert replay.state == "reserved"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -749,6 +1033,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 +1056,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
@@ -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('&', '&').replaceAll('<', '<');
|
||||
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 "<unsafe>" in output["text"] and "+new" in output["text"]
|
||||
assert "Preview truncated" in output["text"]
|
||||
assert 'data-pull-review-file="src/<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": []}
|
||||
@@ -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 = []
|
||||
|
||||
@@ -19,3 +19,17 @@ async def test_dashboard_viewport_allows_mobile_zoom():
|
||||
assert 'name="viewport"' in html
|
||||
assert "width=device-width" in html
|
||||
assert "user-scalable=no" not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="search-preview" role="dialog" aria-modal="true"' in html
|
||||
assert 'aria-labelledby="search-preview-title"' in html
|
||||
assert 'id="claim-search-result"' in html
|
||||
assert 'id="open-search-result-gitea"' in html
|
||||
assert 'src="static/search-preview.js"' in html
|
||||
assert ".search-preview-panel" in html
|
||||
assert "height:100dvh" in html
|
||||
assert "env(safe-area-inset-bottom)" in html
|
||||
@@ -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",
|
||||
}],
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user