Compare commits

..

No commits in common. "8c889e3ffc7ac0a49739bd4b68dfc760eea41a36" and "30a014b6192fd0d946c6445aad8b8d022f20fe91" have entirely different histories.

7 changed files with 6 additions and 809 deletions

View File

@ -15,11 +15,10 @@ python3 -m pip install -r requirements.txt
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a 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 token that can read dashboard data, update the authenticated user's notification
threads, create issue comments, close assigned issues, and submit pull-request threads, create issue comments, and submit pull-request reviews. Pull-request
reviews. Pull-request replies and mobile My Work issue comments use Gitea's replies use Gitea's issue-comment API; native Comment, Approve, and Request changes
issue-comment API; closing an assigned issue and native Comment, Approve, and reviews require repository write permission. Serve the dashboard only to trusted
Request changes reviews require repository write permission. Serve the dashboard users on its own origin; cross-origin API
only to trusted users on its own origin; cross-origin API
access is intentionally disabled. Then start the API and bundled frontend: access is intentionally disabled. Then start the API and bundled frontend:
```bash ```bash

View File

@ -116,19 +116,6 @@ textarea { resize: vertical; min-height: 120px; }
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; } .update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; } .update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-retry { min-height:44px; width:100%; margin-top:10px; } .update-retry { min-height:44px; width:100%; margin-top:10px; }
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.issue-sheet.open { display:flex; }
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.issue-sheet-header button { min-height:44px; }
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.issue-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-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; }
@media (max-width: 600px) { @media (max-width: 600px) {
header { align-items:flex-start; } header { align-items:flex-start; }
.my-work { margin:0; } .my-work { margin:0; }
@ -137,7 +124,6 @@ textarea { resize: vertical; min-height: 120px; }
.work-filter { flex:1 1 calc(50% - 8px); } .work-filter { flex:1 1 calc(50% - 8px); }
.review-sheet-panel { width:100%; border-left:0; padding:14px; } .review-sheet-panel { width:100%; border-left:0; padding:14px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; } .update-sheet-panel { width:100%; border-left:0; padding:14px; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
} }
.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; } .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; } .footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@ -264,34 +250,6 @@ textarea { resize: vertical; min-height: 120px; }
</div> </div>
</div> </div>
<div class="issue-sheet" id="issue-sheet" role="dialog" aria-modal="true" aria-labelledby="issue-sheet-title">
<section class="issue-sheet-panel">
<div class="issue-sheet-header">
<div>
<div class="small" id="issue-sheet-key"></div>
<h3 id="issue-sheet-title">Assigned issue</h3>
</div>
<button id="close-issue-sheet" type="button">Close sheet</button>
</div>
<div id="issue-sheet-status" class="small" aria-live="polite">Choose an issue.</div>
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Retry loading issue</button>
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
<p class="issue-sheet-content" id="issue-sheet-body"></p>
<h2>Recent discussion</h2>
<div id="issue-comments"></div>
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
<h2 id="issue-comment-title">Add comment</h2>
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-issue-comment" type="button">Post comment</button>
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
</section>
<div class="issue-sheet-actions">
<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="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title"> <div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
<section class="update-sheet-panel"> <section class="update-sheet-panel">
<div class="update-sheet-header"> <div class="update-sheet-header">
@ -379,7 +337,6 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/commands.js"></script> <script src="static/commands.js"></script>
<script src="static/widgets.js"></script> <script src="static/widgets.js"></script>
<script src="static/my-work.js"></script> <script src="static/my-work.js"></script>
<script src="static/issue-sheet.js"></script>
<script src="static/review-sheet.js"></script> <script src="static/review-sheet.js"></script>
<script src="static/context-poller.js"></script> <script src="static/context-poller.js"></script>
<script> <script>
@ -425,8 +382,6 @@ textarea { resize: vertical; min-height: 120px; }
let reviewTrigger = null; let reviewTrigger = null;
let selectedUpdate = null; let selectedUpdate = null;
let updateTrigger = null; let updateTrigger = null;
let selectedIssue = null;
let issueTrigger = null;
let progress = null; let progress = null;
let draft = null; let draft = null;
let reviewFiles = []; let reviewFiles = [];
@ -442,7 +397,6 @@ textarea { resize: vertical; min-height: 120px; }
return payload; return payload;
} }
const reviewController = createReviewController({ fetchJson: fetchReviewJson }); const reviewController = createReviewController({ fetchJson: fetchReviewJson });
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; } function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
function setClock() { qs('#clock').textContent = fmt(new Date()); } function setClock() { qs('#clock').textContent = fmt(new Date()); }
@ -670,17 +624,11 @@ textarea { resize: vertical; min-height: 120px; }
if (item.is_review) { if (item.is_review) {
return '<article class="my-work-card"><button class="my-work-card-main review-trigger" data-review-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>'; return '<article class="my-work-card"><button class="my-work-card-main review-trigger" data-review-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
} }
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>';
}
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>'; 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">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.</div>';
document.querySelectorAll('[data-review-index]').forEach(button => { document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], 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-update-index]').forEach(button => { document.querySelectorAll('[data-update-index]').forEach(button => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.updateIndex)]; const item = lastMyWork[Number(button.dataset.updateIndex)];
@ -751,61 +699,6 @@ textarea { resize: vertical; min-height: 120px; }
toggle?.focus(); toggle?.focus();
} }
function renderIssueComment(comment) {
return '<div class="issue-comment"><div class="small">' +
escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
'</div><div class="issue-sheet-content">' +
escapeHtml(comment.body || 'No comment body provided.') + '</div></div>';
}
async function openIssueSheet(item, trigger) {
if (!item) return;
selectedIssue = item;
issueTrigger = trigger;
qs('#issue-sheet').classList.add('open');
qs('#issue-sheet-key').textContent = item.key || '';
qs('#issue-sheet-title').textContent = item.title || 'Assigned issue';
qs('#issue-sheet-status').textContent = 'Loading issue…';
qs('#issue-sheet-body').textContent = '';
qs('#issue-labels').textContent = '';
qs('#issue-assignees').textContent = '';
qs('#issue-comments').textContent = '';
qs('#issue-comment').value = issueController.loadDraft(item);
qs('#issue-comment-status').textContent = '';
qs('#retry-issue-load').hidden = true;
qs('#open-issue-gitea').href = item.url || '#';
qs('#send-issue-comment').disabled = false;
qs('#close-issue').disabled = false;
qs('#close-issue-sheet').focus();
try {
const detail = await issueController.load(item);
if (selectedIssue !== item) return;
qs('#issue-sheet-title').textContent = detail.title || 'Assigned issue';
qs('#issue-sheet-body').textContent = detail.body || 'No description provided.';
qs('#issue-labels').innerHTML = (detail.labels || []).map(label =>
'<span class="pill">' + escapeHtml(label) + '</span>'
).join(' ');
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
qs('#issue-comments').innerHTML = (detail.comments || []).length ?
detail.comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
qs('#open-issue-gitea').href = detail.url || item.url || '#';
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
} catch (error) {
if (selectedIssue !== item) return;
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
qs('#retry-issue-load').hidden = false;
qs('#retry-issue-load').focus();
}
}
function closeIssueSheet() {
qs('#issue-sheet').classList.remove('open');
selectedIssue = null;
if (issueTrigger?.isConnected) issueTrigger.focus();
}
async function openReviewSheet(item, trigger) { async function openReviewSheet(item, trigger) {
selectedReview = item; selectedReview = item;
reviewTrigger = trigger; reviewTrigger = trigger;
@ -1050,74 +943,8 @@ textarea { resize: vertical; min-height: 120px; }
} }
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); }); 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)); qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => { if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); qs('#cmd-palette').classList.toggle('open'); if(qs('#cmd-palette').classList.contains('open')){ qs('#cmd-input').focus(); renderCommands(''); } } });
if (e.key === 'Escape' && selectedIssue) {
e.preventDefault();
closeIssueSheet();
return;
}
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
e.preventDefault();
qs('#cmd-palette').classList.toggle('open');
if (qs('#cmd-palette').classList.contains('open')) {
qs('#cmd-input').focus();
renderCommands('');
}
}
});
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal')); qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#close-issue-sheet').addEventListener('click', closeIssueSheet);
qs('#retry-issue-load').addEventListener('click', () => {
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
});
qs('#issue-comment').addEventListener('input', event => {
if (selectedIssue) issueController.saveDraft(selectedIssue, event.target.value);
});
qs('#send-issue-comment').addEventListener('click', async () => {
if (!selectedIssue) return;
const body = qs('#issue-comment').value.trim();
if (!body) {
qs('#issue-comment-status').textContent = 'Write a comment before posting.';
qs('#issue-comment').focus();
return;
}
const button = qs('#send-issue-comment');
button.disabled = true;
qs('#issue-comment-status').textContent = 'Posting comment…';
try {
const comment = await issueController.comment(selectedIssue, body);
const empty = qs('#issue-comments .muted');
if (empty) empty.remove();
qs('#issue-comments').insertAdjacentHTML('beforeend', renderIssueComment(comment));
qs('#issue-comment').value = '';
qs('#issue-comment-status').textContent = 'Comment posted.';
} catch (error) {
qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#issue-comment').focus();
} finally {
button.disabled = false;
}
});
qs('#close-issue').addEventListener('click', async () => {
if (!selectedIssue || !window.confirm('Close ' + selectedIssue.key + '?')) return;
const closing = selectedIssue;
const button = qs('#close-issue');
button.disabled = true;
qs('#issue-sheet-status').textContent = 'Closing issue…';
try {
await issueController.close(selectedIssue);
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number)
);
closeIssueSheet();
refreshMyWorkView();
qs('#my-work-action-status').textContent = closing.key + ' closed.';
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true)); qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
qs('#retry-update-load').addEventListener('click', () => { qs('#retry-update-load').addEventListener('click', () => {
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork); if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);

View File

@ -1,50 +0,0 @@
function createIssueSheet({ fetchJson, storage }) {
let commentRequest = null;
let closeRequest = 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;
return {
load(item) {
return fetchJson(issuePath(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. */ }
},
close(item) {
if (closeRequest) return closeRequest;
closeRequest = fetchJson(issuePath(item) + '/close', {
method: 'PATCH',
headers: { Accept: 'application/json' },
}).then(result => {
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
return result;
}).finally(() => { closeRequest = null; });
return closeRequest;
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
commentRequest = fetchJson(issuePath(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;
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;

View File

@ -318,99 +318,6 @@ async def reply_to_notification(thread_id: int, body: str) -> dict:
} }
async def close_issue(repository: str, number: int) -> dict:
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"state": "closed"},
)
response.raise_for_status()
issue = response.json()
if not isinstance(issue, dict) or issue.get("state") != "closed":
raise ValueError("Gitea did not confirm issue closure")
return {
"number": issue.get("number"),
"state": "closed",
"closed_at": issue.get("closed_at", "")
if isinstance(issue.get("closed_at"), str)
else "",
}
def _normalize_issue_comment(comment: dict) -> dict:
user_value = comment.get("user")
user: dict = user_value if isinstance(user_value, dict) else {}
return {
"id": comment.get("id"),
"author": user.get("login", "") if isinstance(user.get("login"), str) else "",
"body": comment.get("body", "") if isinstance(comment.get("body"), str) else "",
"created_at": comment.get("created_at", "")
if isinstance(comment.get("created_at"), str)
else "",
"url": _safe_web_url(comment.get("html_url")),
}
async def comment_on_issue(repository: str, number: int, body: str) -> dict:
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues/{number}/comments",
headers=_auth(),
json={"body": body},
)
response.raise_for_status()
comment = response.json()
if not isinstance(comment, dict):
raise ValueError("Gitea comment response was not an object")
return _normalize_issue_comment(comment)
async def issue_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/issues/{number}"
issue, comments = await asyncio.gather(
fetch(base), fetch(f"{base}/comments?limit=20&page=1")
)
if not isinstance(issue, dict):
raise ValueError("Gitea issue response was not an object")
labels_value = issue.get("labels")
labels: list = labels_value if isinstance(labels_value, list) else []
assignees_value = issue.get("assignees")
assignees: list = assignees_value if isinstance(assignees_value, list) else []
comments_value: list = comments if isinstance(comments, list) else []
normalized_comments = [
_normalize_issue_comment(comment)
for comment in comments_value
if isinstance(comment, dict)
]
return {
"repository": repository,
"number": number,
"title": issue.get("title", "") if isinstance(issue.get("title"), str) else "",
"state": issue.get("state", "") if isinstance(issue.get("state"), str) else "",
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
"url": _safe_web_url(issue.get("html_url")),
"labels": [
label["name"] for label in labels
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
"assignees": [
assignee["login"] for assignee in assignees
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
],
"comments": normalized_comments,
}
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 pull_requests() -> list[dict]: async def pull_requests() -> list[dict]:
assigned, review_requested = await asyncio.gather( assigned, review_requested = await asyncio.gather(
fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"), fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"),

View File

@ -52,7 +52,6 @@ CONTEXT_TIMEOUT_SECONDS = 5.0
EVENT_STREAM_TIMEOUT_SECONDS = 5.0 EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0 READINESS_TIMEOUT_SECONDS = 5.0
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0 REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
ISSUE_ACTION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0 NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0 NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0 NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0
@ -94,18 +93,6 @@ class NotificationReply(BaseModel):
return value return value
class IssueComment(BaseModel):
body: str = Field(min_length=1, max_length=10_000)
@field_validator("body")
@classmethod
def strip_body(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("comment must not be blank")
return value
class PullReviewSubmission(BaseModel): class PullReviewSubmission(BaseModel):
decision: str decision: str
body: str = Field(max_length=10_000) body: str = Field(max_length=10_000)
@ -174,10 +161,7 @@ async def prevent_live_api_caching(request, call_next):
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"} or (
request.url.path.startswith("/api/v1/repos/") request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review") and request.url.path.endswith("/review")
) or request.url.path.startswith("/api/v1/notifications") or ( ) or request.url.path.startswith("/api/v1/notifications"):
request.url.path.startswith("/api/v1/repos/")
and "/issues/" in request.url.path
):
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
return response return response
@ -680,80 +664,6 @@ async def reply_to_notification(
return JSONResponse(result, status_code=201) return JSONResponse(result, status_code=201)
@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}"
async def load_assigned_issue():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.issue_detail(repository, number)
try:
return await asyncio.wait_for(
load_assigned_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except TimeoutError:
return JSONResponse(
{"error": "Loading the issue timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The issue is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", status_code=201)
async def comment_on_assigned_issue(
comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0)
):
repository = f"{owner}/{repo}"
async def post_comment():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue 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.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/close")
async def close_assigned_issue(owner: str, repo: str, number: int = PathParam(gt=0)):
repository = f"{owner}/{repo}"
async def close_issue():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.close_issue(repository, number)
try:
return await asyncio.wait_for(close_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The issue could not be closed. It remains in My Work; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review") @app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review")
async def review_detail(owner: str, repo: str, number: int): async def review_detail(owner: str, repo: str, number: int):
async def load_requested_review(): async def load_requested_review():

View File

@ -1,270 +0,0 @@
import asyncio
import httpx
import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
async def assigned(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def detail(repository, number):
return {"repository": repository, "number": number, "title": "Fix mobile flow"}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned, raising=False)
monkeypatch.setattr(main.gitea_proxy, "issue_detail", detail)
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/detail")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"repository": "stackchain/api", "number": 7, "title": "Fix mobile flow"
}
@pytest.mark.anyio
async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch):
cancelled = asyncio.Event()
async def assigned(repository, number):
try:
await asyncio.Event().wait()
finally:
cancelled.set()
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
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/detail")
assert response.status_code == 503
assert response.headers["cache-control"] == "no-store"
assert response.headers["retry-after"] == "1"
assert response.json() == {"error": "Loading the issue timed out. Please retry."}
assert cancelled.is_set()
@pytest.mark.anyio
@pytest.mark.parametrize(
("method", "path", "json_body"),
[
("GET", "/api/v1/repos/stackchain/api/issues/7/detail", None),
("POST", "/api/v1/repos/stackchain/api/issues/7/comments", {"body": "Hello"}),
("PATCH", "/api/v1/repos/stackchain/api/issues/7/close", None),
],
)
async def test_issue_actions_reject_items_not_assigned_to_service_user(
monkeypatch, method, path, json_body
):
async def assigned(repository, number):
return False
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.request(method, path, json=json_body)
assert response.status_code == 404
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
@pytest.mark.parametrize("body", [" ", "x" * 10_001])
async def test_issue_comment_rejects_blank_or_oversized_body_before_upstream(
monkeypatch, body
):
called = False
async def assigned(repository, number):
nonlocal called
called = True
return True
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/7/comments", json={"body": body}
)
assert response.status_code == 422
assert response.headers["cache-control"] == "no-store"
assert called is False
@pytest.mark.anyio
async def test_issue_comment_endpoint_posts_only_to_assigned_issue(monkeypatch):
calls = []
async def assigned(repository, number):
return True
async def comment(repository, number, body):
calls.append((repository, number, body))
return {"id": 82, "author": "timmy", "body": body}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/7/comments",
json={"body": " Ready to ship "},
)
assert response.status_code == 201
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"id": 82, "author": "timmy", "body": "Ready to ship"}
assert calls == [("stackchain/api", 7, "Ready to ship")]
@pytest.mark.anyio
async def test_issue_close_endpoint_mutates_assigned_issue_only_after_confirmation(monkeypatch):
calls = []
async def assigned(repository, number):
return True
async def close(repository, number):
calls.append((repository, number))
return {"number": number, "state": "closed", "closed_at": "now"}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
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/close")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["state"] == "closed"
assert calls == [("stackchain/api", 7)]
@pytest.mark.anyio
async def test_gitea_close_issue_patches_state_and_confirms_closed_response():
requests = []
async def handler(request):
requests.append(request)
return httpx.Response(
200,
json={"number": 7, "state": "closed", "closed_at": "2026-08-07T10:20:00Z"},
)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.close_issue("stackchain/api", 7)
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'{"state":"closed"}'
assert result == {"number": 7, "state": "closed", "closed_at": "2026-08-07T10:20:00Z"}
@pytest.mark.anyio
async def test_gitea_issue_comment_posts_body_and_returns_safe_identity():
requests = []
async def handler(request):
requests.append(request)
return httpx.Response(
201,
json={
"id": 82,
"body": "Ready to ship",
"created_at": "2026-08-07T10:10:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-82",
"user": {"login": "timmy"},
},
)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.comment_on_issue("stackchain/api", 7, "Ready to ship")
finally:
await gitea_proxy.stop_client()
assert len(requests) == 1
assert requests[0].method == "POST"
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues/7/comments"
assert requests[0].content == b'{"body":"Ready to ship"}'
assert result == {
"id": 82,
"author": "timmy",
"body": "Ready to ship",
"created_at": "2026-08-07T10:10:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-82",
}
@pytest.mark.anyio
async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments():
requests = []
async def handler(request):
requests.append((request.method, request.url.path, request.url.query.decode()))
if request.url.path.endswith("/comments"):
return httpx.Response(
200,
json=[
{
"id": 81,
"body": "Latest update",
"created_at": "2026-08-07T10:00:00Z",
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
"user": {"login": "sam"},
}
],
)
return httpx.Response(
200,
json={
"number": 7,
"title": "Fix mobile flow",
"state": "open",
"body": "Full issue context",
"html_url": "https://forge.example/stackchain/api/issues/7",
"labels": [{"name": "P1"}],
"assignees": [{"login": "timmy"}],
},
)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_detail("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert requests == [
("GET", "/api/v1/repos/stackchain/api/issues/7", ""),
("GET", "/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
]
assert result == {
"repository": "stackchain/api",
"number": 7,
"title": "Fix mobile flow",
"state": "open",
"body": "Full issue context",
"url": "https://forge.example/stackchain/api/issues/7",
"labels": ["P1"],
"assignees": ["timmy"],
"comments": [
{
"id": 81,
"author": "sam",
"body": "Latest update",
"created_at": "2026-08-07T10:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-81",
}
],
}

View File

@ -9,7 +9,6 @@ from src.views import dashboard
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js" MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js" REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity(): def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
@ -544,131 +543,6 @@ reader.open(item, [item]).then(() => {{
] ]
def test_issue_sheet_loads_encoded_assigned_issue_detail_path():
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 {{title:'Fix mobile flow'}};
}} }});
controller.load({{repository:'stackchain/api', number:7}}).then(detail =>
process.stdout.write(JSON.stringify({{request, title:detail.title}}))
);
"""
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/detail",
"accept": "application/json",
},
"title": "Fix mobile flow",
}
def test_issue_sheet_comment_is_single_flight_and_preserves_draft_until_success():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let calls = 0;
let release;
const controller = createIssueSheet({{
storage,
fetchJson: (url, options) => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{id:82, body:'Ready'}}); }});
}},
}});
const item = {{repository:'stackchain/api', number:7}};
controller.saveDraft(item, 'Ready');
const first = controller.comment(item, 'Ready');
const duplicate = controller.comment(item, 'Ready');
const during = controller.loadDraft(item);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, during, after:controller.loadDraft(item), 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["during"] == "Ready"
assert output["after"] == ""
assert output["results"] == [{"id": 82, "body": "Ready"}, {"id": 82, "body": "Ready"}]
def test_issue_sheet_close_is_single_flight_and_waits_for_confirmed_closed_state():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let calls = 0;
let release;
const controller = createIssueSheet({{
fetchJson: (url, options) => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{number:7, state:'closed'}}); }});
}},
}});
const item = {{repository:'stackchain/api', number:7}};
const first = controller.close(item);
const duplicate = controller.close(item);
release();
Promise.all([first, duplicate]).then(results =>
process.stdout.write(JSON.stringify({{calls, results}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"calls": 1,
"results": [
{"number": 7, "state": "closed"},
{"number": 7, "state": "closed"},
],
}
@pytest.mark.anyio
async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mutations():
html = await dashboard()
assert 'id="issue-sheet"' in html and 'aria-modal="true"' in html
assert 'class="my-work-card-main issue-trigger"' in html
assert 'id="close-issue-sheet"' in html
assert 'id="retry-issue-load"' in html
assert 'id="issue-sheet-body"' in html
assert 'id="issue-labels"' in html and 'id="issue-assignees"' in html
assert 'id="issue-comments"' in html
assert 'id="issue-comment"' in html and 'maxlength="10000"' in html
assert 'id="send-issue-comment"' in html
assert 'id="close-issue"' in html
assert 'id="open-issue-gitea"' in html and 'rel="noopener noreferrer"' in html
assert '.issue-sheet-panel { width:min(560px,100%);' in html
assert '.issue-sheet-content { overflow-wrap:anywhere;' in html
assert '.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
assert '<script src="static/issue-sheet.js"></script>' in html
assert "issueController.load(item)" in html
assert "issueController.comment(selectedIssue" in html
assert "window.confirm('Close ' + selectedIssue.key + '?')" in html
assert "issueController.close(selectedIssue)" in html
assert "lastMyWork = lastMyWork.filter" in html
assert "if (issueTrigger?.isConnected) issueTrigger.focus()" in html
assert "e.key === 'Escape' && selectedIssue" in html
@pytest.mark.anyio @pytest.mark.anyio
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels(): async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard() html = await dashboard()