Merge pull request 'Plan new mobile issues at capture time' (#222) from timmy/221-plan-new-issues-at-capture into main
All checks were successful
CI / lint (push) Successful in 18s
Release / release-candidate (push) Successful in 6s
CI / build-frontend (push) Successful in 6s

This commit is contained in:
timmy 2026-08-07 19:19:38 +00:00
commit bca27f0330
7 changed files with 280 additions and 7 deletions

View File

@ -16,12 +16,15 @@ 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 and self-assign issues, discover, claim, and release issue assignments, threads, create and self-assign issues, discover, claim, and release issue assignments,
set or clear due dates on assigned issues, create issue comments, close assigned issues, list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews. requests, merge assigned pull requests, and submit pull-request reviews.
Pull-request replies and mobile My Work issue and PR comments use Gitea's Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue issue-comment API; mobile issue capture requires issue
creation and assignment permission. Issue capture and authored mobile actions (issue creation and assignment permission. The New issue sheet can optionally select an open
repository milestone and due date; the dashboard validates both and sends them with
self-assignment in the single create request, so planned work appears in its release
lane immediately. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. Results another worker replays a confirmed result instead of posting duplicate content. Results

View File

@ -15,18 +15,25 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0) (Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20); )).slice(0, 20);
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] }); const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
function loadStored() { function loadStored() {
try { try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null'); const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''}; if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''};
return { const draft = {
repository: String(parsed.repository || ''), repository: String(parsed.repository || ''),
title: String(parsed.title || ''), title: String(parsed.title || ''),
body: String(parsed.body || ''), body: String(parsed.body || ''),
labelIds: safeLabelIds(parsed.labelIds), labelIds: safeLabelIds(parsed.labelIds),
operationId: String(parsed.operationId || '').slice(0, 128), operationId: String(parsed.operationId || '').slice(0, 128),
}; };
const milestoneId = safeMilestoneId(parsed.milestoneId);
const dueDate = safeDueDate(parsed.dueDate);
if (milestoneId !== null) draft.milestoneId = milestoneId;
if (dueDate) draft.dueDate = dueDate;
return draft;
} catch (_error) { } catch (_error) {
return {...emptyDraft(), operationId: ''}; return {...emptyDraft(), operationId: ''};
} }
@ -45,7 +52,12 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
body: String(draft?.body || ''), body: String(draft?.body || ''),
labelIds: safeLabelIds(draft?.labelIds), labelIds: safeLabelIds(draft?.labelIds),
}; };
const unchanged = ['repository', 'title', 'body'].every(key => previous[key] === safe[key]) && const milestoneId = safeMilestoneId(draft?.milestoneId);
const dueDate = safeDueDate(draft?.dueDate);
if (milestoneId !== null) safe.milestoneId = milestoneId;
if (dueDate) safe.dueDate = dueDate;
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
.every(key => (previous[key] || '') === (safe[key] || '')) &&
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds); JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
writeStored({...safe, operationId: unchanged ? previous.operationId : ''}); writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
return safe; return safe;
@ -73,6 +85,13 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
); );
} }
function loadMilestones(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
return fetchJson('api/v1/repos/' + encoded + '/milestones').then(milestones =>
Array.isArray(milestones) ? milestones : []
);
}
function submit(draft) { function submit(draft) {
if (pending) return pending; if (pending) return pending;
const saved = saveDraft(draft); const saved = saveDraft(draft);
@ -88,6 +107,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}, },
body: JSON.stringify({ body: JSON.stringify({
title: saved.title, body: saved.body, label_ids: saved.labelIds, title: saved.title, body: saved.body, label_ids: saved.labelIds,
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
}), }),
}).then(issue => { }).then(issue => {
clearDraft(); clearDraft();
@ -96,7 +117,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
return pending; return pending;
} }
return { saveDraft, loadDraft, loadLabels, submit }; return { saveDraft, loadDraft, loadLabels, loadMilestones, submit };
} }
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture; if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;

View File

@ -206,7 +206,7 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-header button, .create-issue-actions button { min-height:44px; } .create-issue-header button, .create-issue-actions button { min-height:44px; }
.create-issue-form { display:grid; gap:12px; } .create-issue-form { display:grid; gap:12px; }
.create-issue-form label { display:grid; gap:6px; } .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-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; } .create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; } .create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; } .create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
@ -519,6 +519,15 @@ textarea { resize: vertical; min-height: 120px; }
<label for="create-issue-body">Description <span class="small">Optional</span> <label for="create-issue-body">Description <span class="small">Optional</span>
<textarea id="create-issue-body" maxlength="10000"></textarea> <textarea id="create-issue-body" maxlength="10000"></textarea>
</label> </label>
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
<option value="">No milestone</option>
</select>
<span class="small" id="create-issue-milestone-status" aria-live="polite">Choose a repository to load milestones.</span>
</label>
<label for="create-issue-due-date">Due date <span class="small">Optional</span>
<input id="create-issue-due-date" type="date" />
</label>
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status"> <fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
<legend>Labels <span class="small">Optional</span></legend> <legend>Labels <span class="small">Optional</span></legend>
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div> <div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
@ -1757,6 +1766,8 @@ textarea { resize: vertical; min-height: 120px; }
title: qs('#create-issue-title').value, title: qs('#create-issue-title').value,
body: qs('#create-issue-body').value, body: qs('#create-issue-body').value,
labelIds: selectedIssueLabelIds(), labelIds: selectedIssueLabelIds(),
milestoneId: Number(qs('#create-issue-milestone').value) || null,
dueDate: qs('#create-issue-due-date').value,
}); });
} }
@ -1783,6 +1794,30 @@ textarea { resize: vertical; min-height: 120px; }
} }
} }
async function loadIssueMilestones(repository, selectedId = null) {
const select = qs('#create-issue-milestone');
const status = qs('#create-issue-milestone-status');
select.innerHTML = '<option value="">No milestone</option>';
if (!repository) {
status.textContent = 'Choose a repository to load milestones.';
return;
}
status.textContent = 'Loading milestones…';
try {
const milestones = await issueCapture.loadMilestones(repository);
if (qs('#create-issue-repository').value !== repository) return;
select.innerHTML += milestones.map(milestone =>
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
).join('');
if (selectedId) select.value = String(selectedId);
status.textContent = milestones.length ?
'Choose the release lane for this issue.' : 'This repository has no open milestones.';
} catch (_error) {
if (qs('#create-issue-repository').value !== repository) return;
status.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
}
}
function openCreateIssueSheet() { function openCreateIssueSheet() {
const captureDraft = issueCapture.loadDraft(); const captureDraft = issueCapture.loadDraft();
const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean); const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
@ -1795,7 +1830,9 @@ textarea { resize: vertical; min-height: 120px; }
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository; if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
qs('#create-issue-title').value = captureDraft.title; qs('#create-issue-title').value = captureDraft.title;
qs('#create-issue-body').value = captureDraft.body; qs('#create-issue-body').value = captureDraft.body;
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds); loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.'; qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
qs('#submit-new-issue').disabled = !repositories.length; qs('#submit-new-issue').disabled = !repositories.length;
qs('#create-issue-sheet').classList.add('open'); qs('#create-issue-sheet').classList.add('open');
@ -2367,14 +2404,16 @@ textarea { resize: vertical; min-height: 120px; }
saveIssueCaptureDraft(); saveIssueCaptureDraft();
closeCreateIssueSheet(); closeCreateIssueSheet();
}); });
['#create-issue-title', '#create-issue-body'].forEach(selector => ['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
qs(selector).addEventListener('input', saveIssueCaptureDraft) qs(selector).addEventListener('input', saveIssueCaptureDraft)
); );
qs('#create-issue-repository').addEventListener('change', event => { qs('#create-issue-repository').addEventListener('change', event => {
loadIssueLabels(event.target.value); loadIssueLabels(event.target.value);
loadIssueMilestones(event.target.value);
saveIssueCaptureDraft(); saveIssueCaptureDraft();
}); });
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft); qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-form').addEventListener('submit', async event => { qs('#create-issue-form').addEventListener('submit', async event => {
event.preventDefault(); event.preventDefault();
const captureDraft = { const captureDraft = {
@ -2382,6 +2421,8 @@ textarea { resize: vertical; min-height: 120px; }
title: qs('#create-issue-title').value.trim(), title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(), body: qs('#create-issue-body').value.trim(),
labelIds: selectedIssueLabelIds(), labelIds: selectedIssueLabelIds(),
milestoneId: Number(qs('#create-issue-milestone').value) || null,
dueDate: qs('#create-issue-due-date').value,
}; };
if (!captureDraft.repository || !captureDraft.title) { if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.'; qs('#create-issue-status').textContent = 'Choose a repository and add a title.';

View File

@ -751,10 +751,16 @@ async def create_issue(
body: str, body: str,
assignee: str, assignee: str,
label_ids: list[int] | None = None, label_ids: list[int] | None = None,
milestone_id: int | None = None,
due_date: str | None = None,
) -> dict: ) -> dict:
payload: dict = {"title": title, "body": body, "assignee": assignee} payload: dict = {"title": title, "body": body, "assignee": assignee}
if label_ids: if label_ids:
payload["labels"] = label_ids payload["labels"] = label_ids
if milestone_id is not None:
payload["milestone"] = milestone_id
if due_date is not None:
payload["due_date"] = due_date
response = await _get_client().post( response = await _get_client().post(
f"/api/v1/repos/{repository}/issues", f"/api/v1/repos/{repository}/issues",
headers=_auth(), headers=_auth(),
@ -775,6 +781,22 @@ async def create_issue(
raise ValueError("Gitea did not confirm issue self-assignment") raise ValueError("Gitea did not confirm issue self-assignment")
labels_value = issue.get("labels") labels_value = issue.get("labels")
labels = labels_value if isinstance(labels_value, list) else [] labels = labels_value if isinstance(labels_value, list) else []
milestone_value = issue.get("milestone")
milestone = (
{"id": milestone_value["id"], "title": milestone_value["title"]}
if isinstance(milestone_value, dict)
and isinstance(milestone_value.get("id"), int)
and isinstance(milestone_value.get("title"), str)
else None
)
confirmed_due_date = (
issue.get("due_date") if isinstance(issue.get("due_date"), str) else None
)
if (
(milestone_id is not None and (milestone or {}).get("id") != milestone_id)
or (due_date is not None and confirmed_due_date != due_date)
):
raise ValueError("Gitea did not confirm issue release plan")
return { return {
"id": issue.get("id"), "id": issue.get("id"),
"number": issue["number"], "number": issue["number"],
@ -791,6 +813,8 @@ async def create_issue(
if isinstance(item, dict) and isinstance(item.get("name"), str) if isinstance(item, dict) and isinstance(item.get("name"), str)
], ],
"assignees": confirmed_assignees, "assignees": confirmed_assignees,
"milestone": milestone,
"due_date": confirmed_due_date,
"updated_at": issue.get("updated_at", "") "updated_at": issue.get("updated_at", "")
if isinstance(issue.get("updated_at"), str) if isinstance(issue.get("updated_at"), str)
else "", else "",

View File

@ -4,6 +4,7 @@ import os
import time import time
from collections.abc import Awaitable, Coroutine from collections.abc import Awaitable, Coroutine
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
@ -151,6 +152,12 @@ class IssueCreation(BaseModel):
title: str = Field(min_length=1, max_length=255) title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000) body: str = Field(default="", max_length=10_000)
label_ids: list[int] = Field(default_factory=list, max_length=20) label_ids: list[int] = Field(default_factory=list, max_length=20)
milestone_id: PositiveInt | None = None
due_date: str | None = Field(
default=None,
pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$",
max_length=20,
)
@field_validator("title") @field_validator("title")
@classmethod @classmethod
@ -165,6 +172,13 @@ class IssueCreation(BaseModel):
def strip_issue_body(cls, value: str) -> str: def strip_issue_body(cls, value: str) -> str:
return value.strip() return value.strip()
@field_validator("due_date")
@classmethod
def validate_due_date(cls, value: str | None) -> str | None:
if value is not None:
datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
return value
class IssueContentUpdate(BaseModel): class IssueContentUpdate(BaseModel):
title: str = Field(min_length=1, max_length=255) title: str = Field(min_length=1, max_length=255)
@ -1439,6 +1453,27 @@ async def create_assigned_issue(
} }
if any(label_id not in valid_label_ids for label_id in creation.label_ids): if any(label_id not in valid_label_ids for label_id in creation.label_ids):
raise HTTPException(status_code=422, detail="Unknown repository label") raise HTTPException(status_code=422, detail="Unknown repository label")
if creation.milestone_id is not None:
available_milestones = await gitea_proxy.repo_milestones(repository)
valid_milestone_ids = {
item.get("id")
for item in available_milestones
if isinstance(item, dict) and isinstance(item.get("id"), int)
}
if creation.milestone_id not in valid_milestone_ids:
raise HTTPException(
status_code=422, detail="Unknown open repository milestone"
)
if creation.milestone_id is not None or creation.due_date is not None:
return await gitea_proxy.create_issue(
repository,
creation.title,
creation.body,
login,
creation.label_ids,
creation.milestone_id,
creation.due_date,
)
return await gitea_proxy.create_issue( return await gitea_proxy.create_issue(
repository, creation.title, creation.body, login, creation.label_ids repository, creation.title, creation.body, login, creation.label_ids
) )
@ -1453,6 +1488,8 @@ async def create_assigned_issue(
creation.title, creation.title,
creation.body, creation.body,
tuple(creation.label_ids), tuple(creation.label_ids),
creation.milestone_id,
creation.due_date,
), ),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS, timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
) )

View File

@ -373,6 +373,55 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])] assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
@pytest.mark.anyio
async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch):
calls = []
async def user():
return {"login": "timmy"}
async def available_repos():
return [{"full_name": "stackchain/api"}]
async def milestones(repository):
assert repository == "stackchain/api"
return [{"id": 9, "title": "August RC"}]
async def create(repository, title, body, assignee, label_ids, milestone_id, due_date):
calls.append((repository, title, body, assignee, label_ids, milestone_id, due_date))
return {
"id": 221, "number": 221, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"milestone": {"id": 9, "title": "August RC"},
"due_date": "2026-08-31T23:59:59Z",
"updated_at": "2026-08-07T20:00:00Z",
"url": "https://forge.example/stackchain/api/issues/221",
}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues",
json={
"title": "Ship mobile plan",
"milestone_id": 9,
"due_date": "2026-08-31T23:59:59Z",
},
)
assert response.status_code == 201
assert response.json()["milestone"] == {"id": 9, "title": "August RC"}
assert response.json()["due_date"] == "2026-08-31T23:59:59Z"
assert calls == [(
"stackchain/api", "Ship mobile plan", "", "timmy", [], 9,
"2026-08-31T23:59:59Z",
)]
@pytest.mark.anyio @pytest.mark.anyio
async def test_create_issue_replays_one_upstream_result_for_concurrent_idempotent_requests(monkeypatch): async def test_create_issue_replays_one_upstream_result_for_concurrent_idempotent_requests(monkeypatch):
calls = 0 calls = 0
@ -630,6 +679,26 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
assert result["labels"] == ["P0"] assert result["labels"] == ["P0"]
@pytest.mark.anyio
async def test_gitea_create_issue_requires_confirmed_release_plan():
async def handler(_request):
return httpx.Response(201, json={
"id": 221, "number": 221, "title": "Planned work", "state": "open",
"assignees": [{"login": "timmy"}], "labels": [],
"milestone": None, "due_date": "2026-09-01T23:59:59Z",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(ValueError, match="release plan"):
await gitea_proxy.create_issue(
"stackchain/api", "Planned work", "", "timmy", [], 9,
"2026-08-31T23:59:59Z",
)
finally:
await gitea_proxy.stop_client()
@pytest.mark.anyio @pytest.mark.anyio
async def test_gitea_repo_labels_returns_safe_touch_picker_options(): async def test_gitea_repo_labels_returns_safe_touch_picker_options():
requests = [] requests = []
@ -702,6 +771,27 @@ async def test_create_issue_rejects_blank_title_before_upstream(monkeypatch):
assert called is False assert called is False
@pytest.mark.anyio
async def test_create_issue_rejects_nonexistent_due_date_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": "Impossible plan", "due_date": "2026-02-31T23:59:59Z"},
)
assert response.status_code == 422
assert called is False
@pytest.mark.anyio @pytest.mark.anyio
async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch): async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
created = False created = False

View File

@ -521,6 +521,19 @@ async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_b
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
@pytest.mark.anyio
async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
html = await dashboard()
assert 'id="create-issue-milestone"' in html
assert 'id="create-issue-due-date" type="date"' in html
assert 'id="create-issue-milestone-status" aria-live="polite"' in html
assert '.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px;' in html
assert 'issueCapture.loadMilestones(repository)' in html
assert "milestoneId: Number(qs('#create-issue-milestone').value) || null" in html
assert "dueDate: qs('#create-issue-due-date').value" in html
@pytest.mark.anyio @pytest.mark.anyio
async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor(): async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor():
html = await dashboard() html = await dashboard()
@ -1245,6 +1258,50 @@ capture.loadLabels('stackchain/api').then(labels => process.stdout.write(JSON.st
assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"] assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"]
def test_issue_capture_persists_and_submits_milestone_and_due_date():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const calls = [];
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const capture = createIssueCapture({{
storage,
createOperationId: () => 'planned-operation',
fetchJson: (url, options) => {{
calls.push({{url, body:options?.body ? JSON.parse(options.body) : null}});
if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}}]);
return Promise.resolve({{number:221, milestone:{{id:9,title:'August RC'}}, due_date:'2026-08-31T23:59:59Z'}});
}},
}});
const draft = {{repository:'stackchain/api', title:'Ship plan', body:'', labelIds:[], milestoneId:9, dueDate:'2026-08-31'}};
capture.saveDraft(draft);
Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.loadDraft())]).then(results =>
process.stdout.write(JSON.stringify({{calls, stored:results[1], milestones:results[0]}}))
);
"""
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/milestones", "body": None},
{
"url": "api/v1/repos/stackchain/api/issues",
"body": {
"title": "Ship plan", "body": "", "label_ids": [],
"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z",
},
},
]
assert output["milestones"] == [{"id": 9, "title": "August RC"}]
assert output["stored"]["number"] == 221
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"},