Create and self-assign issues from mobile My Work #160

Merged
timmy merged 1 commits from timmy/159-mobile-issue-capture into main 2026-08-07 02:59:06 +00:00
7 changed files with 451 additions and 4 deletions

View File

@ -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
token that can read dashboard data, update the authenticated user's notification
threads, 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; closing an assigned issue and native Comment, Approve, and
Request changes reviews require repository write permission. Serve the dashboard
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.
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:

View 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;

View File

@ -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 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; }
.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) {
header { align-items:flex-start; }
.my-work { margin:0; }
@ -138,6 +148,7 @@ textarea { resize: vertical; min-height: 120px; }
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
.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; }
.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>
<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="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>
@ -292,6 +304,31 @@ textarea { resize: vertical; min-height: 120px; }
</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">
<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 &amp; 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">
<section class="update-sheet-panel">
<div class="update-sheet-header">
@ -380,6 +417,7 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/widgets.js"></script>
<script src="static/my-work.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/context-poller.js"></script>
<script>
@ -427,6 +465,7 @@ textarea { resize: vertical; min-height: 120px; }
let updateTrigger = null;
let selectedIssue = null;
let issueTrigger = null;
let creatingIssue = false;
let progress = null;
let draft = null;
let reviewFiles = [];
@ -443,6 +482,7 @@ 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 });
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
function setClock() { qs('#clock').textContent = fmt(new Date()); }
@ -806,6 +846,38 @@ textarea { resize: vertical; min-height: 120px; }
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) {
selectedReview = item;
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('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && creatingIssue) {
e.preventDefault();
saveIssueCaptureDraft();
closeCreateIssueSheet();
return;
}
if (e.key === 'Escape' && selectedIssue) {
e.preventDefault();
closeIssueSheet();
@ -1066,6 +1144,46 @@ textarea { resize: vertical; min-height: 120px; }
}
});
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('#retry-issue-load').addEventListener('click', () => {
if (selectedIssue) openIssueSheet(selectedIssue, issueTrigger);

View File

@ -364,6 +364,44 @@ 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:
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:
base = f"repos/{repository}/issues/{number}"
issue, comments = await asyncio.gather(

View File

@ -106,6 +106,24 @@ class IssueComment(BaseModel):
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):
decision: str
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 (
request.url.path.startswith("/api/v1/repos/")
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"
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)
async def comment_on_assigned_issue(
comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0)

View File

@ -6,6 +6,118 @@ import pytest
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
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
async def assigned(repository, number):

View File

@ -10,6 +10,7 @@ from src.views import dashboard
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"
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}
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():
payload = {
"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
@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
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard()