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