Merge pull request 'Create and self-assign issues from mobile My Work' (#160) from timmy/159-mobile-issue-capture into main
This commit is contained in:
commit
35e46f81f1
10
README.md
10
README.md
|
|
@ -15,10 +15,12 @@ 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 and self-assign issues, create issue comments, close assigned
|
||||||
reviews. Pull-request replies and mobile My Work issue comments use Gitea's
|
issues, and submit pull-request reviews. Pull-request replies and mobile My Work
|
||||||
issue-comment API; closing an assigned issue and native Comment, Approve, and
|
issue comments use Gitea's issue-comment API; mobile issue capture requires issue
|
||||||
Request changes reviews require repository write permission. Serve the dashboard
|
creation and assignment permission, while closing an assigned issue and native
|
||||||
|
Comment, Approve, and Request changes reviews require repository write permission.
|
||||||
|
Serve the dashboard
|
||||||
only to trusted 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:
|
||||||
|
|
||||||
|
|
|
||||||
53
frontend/create-issue-sheet.js
Normal file
53
frontend/create-issue-sheet.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
function createIssueCapture({ fetchJson, storage }) {
|
||||||
|
const storageKey = 'stackchain.issue-capture.v1';
|
||||||
|
let pending = null;
|
||||||
|
const emptyDraft = () => ({ repository: '', title: '', body: '' });
|
||||||
|
|
||||||
|
function saveDraft(draft) {
|
||||||
|
const safe = {
|
||||||
|
repository: String(draft?.repository || ''),
|
||||||
|
title: String(draft?.title || ''),
|
||||||
|
body: String(draft?.body || ''),
|
||||||
|
};
|
||||||
|
try { storage.setItem(storageKey, JSON.stringify(safe)); }
|
||||||
|
catch (_error) { /* Keep the form as the in-memory fallback. */ }
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDraft() {
|
||||||
|
try { storage.removeItem(storageKey); }
|
||||||
|
catch (_error) { /* Confirmed creation remains authoritative. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(draft) {
|
||||||
|
if (pending) return pending;
|
||||||
|
const saved = saveDraft(draft);
|
||||||
|
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 }),
|
||||||
|
}).then(issue => {
|
||||||
|
clearDraft();
|
||||||
|
return issue;
|
||||||
|
}).finally(() => { pending = null; });
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { saveDraft, loadDraft, submit };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||||
|
|
@ -129,6 +129,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
.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-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||||
|
.new-issue { min-height:44px; }
|
||||||
|
.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; }
|
||||||
|
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||||
|
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||||||
|
.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-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||||
@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; }
|
||||||
|
|
@ -138,6 +148,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.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; }
|
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||||
|
.create-issue-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; }
|
||||||
|
|
@ -163,6 +174,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<h2>My Work</h2>
|
<h2>My Work</h2>
|
||||||
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
|
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="new-issue" id="new-issue" type="button">New issue</button>
|
||||||
<div class="work-filters" aria-label="Filter My Work">
|
<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="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>
|
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||||
|
|
@ -292,6 +304,31 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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">
|
||||||
|
<h3 id="create-issue-heading">New issue</h3>
|
||||||
|
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<form class="create-issue-form" id="create-issue-form">
|
||||||
|
<label for="create-issue-repository">Repository
|
||||||
|
<select id="create-issue-repository" required></select>
|
||||||
|
</label>
|
||||||
|
<label for="create-issue-title">Title
|
||||||
|
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label for="create-issue-body">Description <span class="small">Optional</span>
|
||||||
|
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||||||
|
</label>
|
||||||
|
<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>
|
||||||
|
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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">
|
||||||
|
|
@ -380,6 +417,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<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/issue-sheet.js"></script>
|
||||||
|
<script src="static/create-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>
|
||||||
|
|
@ -427,6 +465,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
let updateTrigger = null;
|
let updateTrigger = null;
|
||||||
let selectedIssue = null;
|
let selectedIssue = null;
|
||||||
let issueTrigger = null;
|
let issueTrigger = null;
|
||||||
|
let creatingIssue = false;
|
||||||
let progress = null;
|
let progress = null;
|
||||||
let draft = null;
|
let draft = null;
|
||||||
let reviewFiles = [];
|
let reviewFiles = [];
|
||||||
|
|
@ -443,6 +482,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
const reviewController = createReviewController({ fetchJson: fetchReviewJson });
|
const reviewController = createReviewController({ fetchJson: fetchReviewJson });
|
||||||
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
|
const issueCapture = createIssueCapture({ 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()); }
|
||||||
|
|
@ -806,6 +846,38 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
if (issueTrigger?.isConnected) issueTrigger.focus();
|
if (issueTrigger?.isConnected) issueTrigger.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveIssueCaptureDraft() {
|
||||||
|
issueCapture.saveDraft({
|
||||||
|
repository: qs('#create-issue-repository').value,
|
||||||
|
title: qs('#create-issue-title').value,
|
||||||
|
body: qs('#create-issue-body').value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateIssueSheet() {
|
||||||
|
const captureDraft = issueCapture.loadDraft();
|
||||||
|
const repositories = lastContextSnapshot?.repos || [];
|
||||||
|
qs('#create-issue-repository').innerHTML = repositories.map(repository =>
|
||||||
|
'<option value="' + escAttr(repository.full_name) + '">' + escapeHtml(repository.full_name) + '</option>'
|
||||||
|
).join('');
|
||||||
|
if (repositories.some(repository => repository.full_name === captureDraft.repository)) {
|
||||||
|
qs('#create-issue-repository').value = captureDraft.repository;
|
||||||
|
}
|
||||||
|
qs('#create-issue-title').value = captureDraft.title;
|
||||||
|
qs('#create-issue-body').value = captureDraft.body;
|
||||||
|
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');
|
||||||
|
creatingIssue = true;
|
||||||
|
qs('#create-issue-title').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateIssueSheet() {
|
||||||
|
qs('#create-issue-sheet').classList.remove('open');
|
||||||
|
creatingIssue = false;
|
||||||
|
qs('#new-issue').focus();
|
||||||
|
}
|
||||||
|
|
||||||
async function openReviewSheet(item, trigger) {
|
async function openReviewSheet(item, trigger) {
|
||||||
selectedReview = item;
|
selectedReview = item;
|
||||||
reviewTrigger = trigger;
|
reviewTrigger = trigger;
|
||||||
|
|
@ -1051,6 +1123,12 @@ 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.key === 'Escape' && creatingIssue) {
|
||||||
|
e.preventDefault();
|
||||||
|
saveIssueCaptureDraft();
|
||||||
|
closeCreateIssueSheet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.key === 'Escape' && selectedIssue) {
|
if (e.key === 'Escape' && selectedIssue) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
closeIssueSheet();
|
closeIssueSheet();
|
||||||
|
|
@ -1066,6 +1144,46 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||||
|
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 =>
|
||||||
|
qs(selector).addEventListener('input', 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(),
|
||||||
|
};
|
||||||
|
if (!captureDraft.repository || !captureDraft.title) {
|
||||||
|
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
||||||
|
qs('#create-issue-title').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const button = qs('#submit-new-issue');
|
||||||
|
button.disabled = true;
|
||||||
|
qs('#create-issue-status').textContent = 'Creating issue…';
|
||||||
|
try {
|
||||||
|
const confirmed = await issueCapture.submit(captureDraft);
|
||||||
|
lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
||||||
|
lastMyWork = buildMyWork(lastContextSnapshot);
|
||||||
|
const created = lastMyWork.find(item =>
|
||||||
|
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
|
||||||
|
);
|
||||||
|
closeCreateIssueSheet();
|
||||||
|
refreshMyWorkView();
|
||||||
|
qs('#my-work-action-status').textContent = created.key + ' created and assigned to you.';
|
||||||
|
openIssueSheet(created, qs('#new-issue'));
|
||||||
|
} catch (error) {
|
||||||
|
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||||
|
button.disabled = false;
|
||||||
|
qs('#create-issue-title').focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
qs('#close-issue-sheet').addEventListener('click', closeIssueSheet);
|
qs('#close-issue-sheet').addEventListener('click', closeIssueSheet);
|
||||||
qs('#retry-issue-load').addEventListener('click', () => {
|
qs('#retry-issue-load').addEventListener('click', () => {
|
||||||
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,44 @@ async def comment_on_issue(repository: str, number: int, body: str) -> dict:
|
||||||
return _normalize_issue_comment(comment)
|
return _normalize_issue_comment(comment)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_issue(repository: str, title: str, body: str, assignee: str) -> dict:
|
||||||
|
response = await _get_client().post(
|
||||||
|
f"/api/v1/repos/{repository}/issues",
|
||||||
|
headers=_auth(),
|
||||||
|
json={"title": title, "body": body, "assignee": assignee},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
issue = response.json()
|
||||||
|
if not isinstance(issue, dict) or not isinstance(issue.get("number"), int):
|
||||||
|
raise ValueError("Gitea did not confirm issue creation")
|
||||||
|
assignees_value = issue.get("assignees")
|
||||||
|
assignees = assignees_value if isinstance(assignees_value, list) else []
|
||||||
|
confirmed_assignees = [
|
||||||
|
item["login"]
|
||||||
|
for item in assignees
|
||||||
|
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||||
|
]
|
||||||
|
if assignee not in confirmed_assignees:
|
||||||
|
raise ValueError("Gitea did not confirm issue self-assignment")
|
||||||
|
return {
|
||||||
|
"id": issue.get("id"),
|
||||||
|
"number": issue["number"],
|
||||||
|
"title": issue.get("title", "")
|
||||||
|
if isinstance(issue.get("title"), str)
|
||||||
|
else "",
|
||||||
|
"state": issue.get("state", "")
|
||||||
|
if isinstance(issue.get("state"), str)
|
||||||
|
else "",
|
||||||
|
"repository": repository,
|
||||||
|
"labels": [],
|
||||||
|
"assignees": confirmed_assignees,
|
||||||
|
"updated_at": issue.get("updated_at", "")
|
||||||
|
if isinstance(issue.get("updated_at"), str)
|
||||||
|
else "",
|
||||||
|
"url": _safe_web_url(issue.get("html_url")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def issue_detail(repository: str, number: int) -> dict:
|
async def issue_detail(repository: str, number: int) -> dict:
|
||||||
base = f"repos/{repository}/issues/{number}"
|
base = f"repos/{repository}/issues/{number}"
|
||||||
issue, comments = await asyncio.gather(
|
issue, comments = await asyncio.gather(
|
||||||
|
|
|
||||||
54
src/main.py
54
src/main.py
|
|
@ -106,6 +106,24 @@ class IssueComment(BaseModel):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class IssueCreation(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
body: str = Field(default="", max_length=10_000)
|
||||||
|
|
||||||
|
@field_validator("title")
|
||||||
|
@classmethod
|
||||||
|
def strip_title(cls, value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError("title must not be blank")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("body")
|
||||||
|
@classmethod
|
||||||
|
def strip_issue_body(cls, value: str) -> str:
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
class PullReviewSubmission(BaseModel):
|
class PullReviewSubmission(BaseModel):
|
||||||
decision: str
|
decision: str
|
||||||
body: str = Field(max_length=10_000)
|
body: str = Field(max_length=10_000)
|
||||||
|
|
@ -177,6 +195,9 @@ async def prevent_live_api_caching(request, call_next):
|
||||||
) or request.url.path.startswith("/api/v1/notifications") or (
|
) or request.url.path.startswith("/api/v1/notifications") or (
|
||||||
request.url.path.startswith("/api/v1/repos/")
|
request.url.path.startswith("/api/v1/repos/")
|
||||||
and "/issues/" in request.url.path
|
and "/issues/" in request.url.path
|
||||||
|
) or (
|
||||||
|
request.url.path.startswith("/api/v1/repos/")
|
||||||
|
and request.url.path.endswith("/issues")
|
||||||
):
|
):
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
return response
|
return response
|
||||||
|
|
@ -709,6 +730,39 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201)
|
||||||
|
async def create_assigned_issue(creation: IssueCreation, owner: str, repo: str):
|
||||||
|
repository = f"{owner}/{repo}"
|
||||||
|
|
||||||
|
async def create_issue():
|
||||||
|
user, available = await asyncio.gather(
|
||||||
|
gitea_proxy.current_user(), gitea_proxy.repos()
|
||||||
|
)
|
||||||
|
login = user.get("login") if isinstance(user, dict) else None
|
||||||
|
accessible = {
|
||||||
|
item.get("full_name")
|
||||||
|
for item in (available if isinstance(available, list) else [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
|
if not login or repository not in accessible:
|
||||||
|
raise HTTPException(status_code=404, detail="Repository not found")
|
||||||
|
return await gitea_proxy.create_issue(
|
||||||
|
repository, creation.title, creation.body, login
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(create_issue(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "The issue could not be created. 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}/issues/{number}/comments", status_code=201)
|
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", status_code=201)
|
||||||
async def comment_on_assigned_issue(
|
async def comment_on_assigned_issue(
|
||||||
comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0)
|
comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,118 @@ import pytest
|
||||||
from src import gitea_proxy, main
|
from src import gitea_proxy, main
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirmed_issue(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
async def available_repos():
|
||||||
|
return [{"full_name": "stackchain/api"}]
|
||||||
|
|
||||||
|
async def create(repository, title, body, assignee):
|
||||||
|
calls.append((repository, title, body, assignee))
|
||||||
|
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, 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 "},
|
||||||
|
)
|
||||||
|
|
||||||
|
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")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmation():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
requests.append(request)
|
||||||
|
return httpx.Response(201, json={
|
||||||
|
"id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
|
||||||
|
"updated_at": "2026-08-07T03:00:00Z",
|
||||||
|
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||||
|
"assignees": [{"login": "timmy"}],
|
||||||
|
})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.create_issue(
|
||||||
|
"stackchain/api", "Capture mobile work", "Context", "timmy"
|
||||||
|
)
|
||||||
|
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"
|
||||||
|
assert requests[0].content == (
|
||||||
|
b'{"title":"Capture mobile work","body":"Context","assignee":"timmy"}'
|
||||||
|
)
|
||||||
|
assert result["repository"] == "stackchain/api"
|
||||||
|
assert result["number"] == 17
|
||||||
|
assert result["assignees"] == ["timmy"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_issue_rejects_blank_title_before_upstream(monkeypatch):
|
||||||
|
called = False
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||||
|
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": " ", "body": "Context"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_gitea_create_issue_requires_confirmed_self_assignment():
|
||||||
|
async def handler(_request):
|
||||||
|
return httpx.Response(201, json={
|
||||||
|
"id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
|
||||||
|
"html_url": "https://forge.example/stackchain/api/issues/17", "assignees": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError, match="self-assignment"):
|
||||||
|
await gitea_proxy.create_issue(
|
||||||
|
"stackchain/api", "Capture mobile work", "", "timmy"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
|
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
|
||||||
async def assigned(repository, number):
|
async def assigned(repository, number):
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ 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"
|
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||||
|
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-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():
|
||||||
|
|
@ -117,6 +118,56 @@ 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}
|
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1, "update": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success():
|
||||||
|
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),
|
||||||
|
}};
|
||||||
|
let calls = [];
|
||||||
|
let release;
|
||||||
|
const capture = createIssueCapture({{
|
||||||
|
storage,
|
||||||
|
fetchJson: (url, options) => {{
|
||||||
|
calls.push({{url, options}});
|
||||||
|
return new Promise(resolve => {{ release = () => resolve({{
|
||||||
|
id:81, number:17, title:'Capture work', state:'open', repository:'stackchain/api',
|
||||||
|
labels:[], assignees:['timmy'], url:'https://forge.example/issues/17'
|
||||||
|
}}); }});
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context'}};
|
||||||
|
capture.saveDraft(draft);
|
||||||
|
const first = capture.submit(draft);
|
||||||
|
const duplicate = capture.submit(draft);
|
||||||
|
const during = capture.loadDraft();
|
||||||
|
release();
|
||||||
|
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||||
|
calls:calls.map(call => ({{url:call.url, method:call.options.method,
|
||||||
|
body:JSON.parse(call.options.body)}})), during, after:capture.loadDraft(), 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",
|
||||||
|
"method": "POST",
|
||||||
|
"body": {"title": "Capture work", "body": "Context"},
|
||||||
|
}]
|
||||||
|
assert output["during"] == {
|
||||||
|
"repository": "stackchain/api", "title": "Capture work", "body": "Context"
|
||||||
|
}
|
||||||
|
assert output["after"] == {"repository": "", "title": "", "body": ""}
|
||||||
|
assert output["results"][0]["number"] == 17
|
||||||
|
assert output["results"][1]["number"] == 17
|
||||||
|
|
||||||
|
|
||||||
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
|
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
|
||||||
payload = {
|
payload = {
|
||||||
"user": {"login": "timmy"},
|
"user": {"login": "timmy"},
|
||||||
|
|
@ -669,6 +720,25 @@ async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mut
|
||||||
assert "e.key === 'Escape' && selectedIssue" in html
|
assert "e.key === 'Escape' && selectedIssue" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="new-issue"' in html and 'New issue' in html
|
||||||
|
assert 'id="create-issue-sheet"' in html and 'aria-modal="true"' in html
|
||||||
|
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="submit-new-issue"' in html
|
||||||
|
assert '.create-issue-sheet.open { display:flex; }' in html
|
||||||
|
assert 'height:100dvh;' in html
|
||||||
|
assert 'env(safe-area-inset-bottom)' in html
|
||||||
|
assert '<script src="static/create-issue-sheet.js"></script>' in html
|
||||||
|
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
|
||||||
|
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
|
||||||
|
assert 'openIssueSheet(created' 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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user