feat: schedule assigned issues from mobile (#191)
All checks were successful
CI / lint (pull_request) Successful in 17s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy 2026-08-07 11:29:35 +00:00
parent 5406ee2297
commit c639b09aaf
11 changed files with 378 additions and 6 deletions

View File

@ -16,7 +16,8 @@ python3 -m pip install -r requirements.txt
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, discover, claim, and release issue assignments,
create issue comments, close assigned issues, inspect/comment on assigned pull
set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue

View File

@ -146,6 +146,10 @@ textarea { resize: vertical; min-height: 120px; }
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
.issue-label-option input { width:20px; height:20px; flex:0 0 auto; }
.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; }
.issue-due-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-due-editor input { box-sizing:border-box; width:100%; max-width:100%; }
.issue-due-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.issue-due-editor input, .issue-due-editor button { min-height:44px; }
.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; }
@ -361,6 +365,16 @@ textarea { resize: vertical; min-height: 120px; }
<div class="small" id="issue-label-status" aria-live="polite">Load an issue to edit labels.</div>
<button id="save-issue-labels" type="button" disabled>Save labels</button>
</fieldset>
<fieldset class="issue-due-editor" aria-describedby="issue-due-status">
<legend>Due date</legend>
<label for="issue-due-date" class="small">Schedule this assigned issue</label>
<input id="issue-due-date" type="date" disabled />
<div class="issue-due-actions">
<button id="save-issue-due-date" type="button" disabled>Save due date</button>
<button id="clear-issue-due-date" type="button" disabled>Clear due date</button>
</div>
<div id="issue-due-status" class="small" aria-live="assertive">Load an issue to schedule it.</div>
</fieldset>
<p class="issue-sheet-content" id="issue-sheet-body"></p>
<button id="edit-issue-content" type="button" disabled>Edit issue</button>
<form class="issue-edit-form" id="issue-edit-form" hidden>
@ -934,6 +948,7 @@ textarea { resize: vertical; min-height: 120px; }
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
(item.due_label ? ' <span class="pill due-badge">' + escapeHtml(item.due_label) + '</span>' : '') +
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
@ -1088,6 +1103,11 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-label-list').textContent = '';
qs('#issue-label-status').textContent = 'Loading labels…';
qs('#save-issue-labels').disabled = true;
qs('#issue-due-date').value = '';
qs('#issue-due-date').disabled = true;
qs('#save-issue-due-date').disabled = true;
qs('#clear-issue-due-date').disabled = true;
qs('#issue-due-status').textContent = 'Loading due date…';
qs('#retry-issue-load').hidden = true;
qs('#open-issue-gitea').href = item.url || '#';
qs('#send-issue-comment').disabled = false;
@ -1113,6 +1133,13 @@ textarea { resize: vertical; min-height: 120px; }
qs('#open-issue-gitea').href = detail.url || item.url || '#';
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
qs('#edit-issue-content').disabled = false;
const dueDraft = issueController.loadDueDateDraft(item);
qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10);
qs('#issue-due-date').disabled = false;
qs('#save-issue-due-date').disabled = false;
qs('#clear-issue-due-date').disabled = !detail.due_date;
qs('#issue-due-status').textContent = detail.due_date ?
'Due ' + new Date(detail.due_date).toLocaleDateString() : 'No due date set.';
} catch (error) {
if (selectedIssue !== item) return;
qs('#issue-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
@ -1853,6 +1880,45 @@ textarea { resize: vertical; min-height: 120px; }
button.disabled = false;
}
});
async function saveSelectedIssueDueDate(dueDate) {
if (!selectedIssue || !lastContextSnapshot) return;
const editing = selectedIssue;
const saveButton = qs('#save-issue-due-date');
const clearButton = qs('#clear-issue-due-date');
saveButton.disabled = true;
clearButton.disabled = true;
qs('#issue-due-status').textContent = dueDate ? 'Saving due date…' : 'Clearing due date…';
try {
const confirmed = await issueController.updateDueDate(editing, dueDate);
lastContextSnapshot = buildMyWork.replaceIssueDueDate(
lastContextSnapshot, editing.repository, editing.number, confirmed.due_date
);
selectedIssue = { ...editing, due_date: confirmed.due_date };
selectedIssueDetail = { ...selectedIssueDetail, due_date: confirmed.due_date };
qs('#issue-due-date').value = String(confirmed.due_date || '').slice(0, 10);
paintMyWork(lastContextSnapshot);
qs('#issue-due-status').textContent = confirmed.due_date ?
'Due date saved. My Work reprioritized.' : 'Due date cleared.';
} catch (error) {
qs('#issue-due-status').textContent = error.message + ' Your selection is safe; retry.';
qs('#issue-due-date').focus();
} finally {
saveButton.disabled = false;
clearButton.disabled = !selectedIssueDetail?.due_date;
}
}
qs('#save-issue-due-date').addEventListener('click', () => {
const value = qs('#issue-due-date').value;
if (!value) {
qs('#issue-due-status').textContent = 'Choose a date or use Clear due date.';
qs('#issue-due-date').focus();
return;
}
saveSelectedIssueDueDate(value + 'T23:59:59Z');
});
qs('#clear-issue-due-date').addEventListener('click', () => saveSelectedIssueDueDate(null));
qs('#send-issue-comment').addEventListener('click', async () => {
if (!selectedIssue) return;
const body = qs('#issue-comment').value.trim();

View File

@ -4,12 +4,14 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
let releaseRequest = null;
let labelRequest = null;
let editRequest = null;
let dueDateRequest = null;
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
const operationKey = item => draftKey(item) + ':operation';
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number;
const dueDateDraftKey = item => 'stackchain.issue-due-date.v1:' + item.repository + '#' + item.number;
return {
load(item) {
@ -65,6 +67,32 @@ function createIssueSheet({ fetchJson, storage, createOperationId = () => global
}).finally(() => { editRequest = null; });
return editRequest;
},
loadDueDateDraft(item) {
try {
const raw = storage?.getItem(dueDateDraftKey(item));
if (raw === null || raw === undefined) return null;
const value = JSON.parse(raw);
return typeof value === 'string' ? value : null;
} catch (_error) { return null; }
},
updateDueDate(item, dueDate) {
if (dueDateRequest) return dueDateRequest;
try { storage?.setItem(dueDateDraftKey(item), JSON.stringify(dueDate)); }
catch (_error) { /* The date input remains the fallback. */ }
dueDateRequest = fetchJson(issuePath(item) + '/due-date', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ due_date: dueDate }),
}).then(result => {
if (result?.number !== item.number || result?.due_date !== dueDate) {
throw new Error('Issue due date was not confirmed.');
}
try { storage?.removeItem(dueDateDraftKey(item)); }
catch (_error) { /* Confirmed upstream deadline is authoritative. */ }
return result;
}).finally(() => { dueDateRequest = null; });
return dueDateRequest;
},
loadLabelDraft(item) {
try {
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');

View File

@ -1,4 +1,20 @@
function buildMyWork(data) {
function issueDueState(dueDate, now) {
if (!dueDate) return null;
const due = new Date(dueDate);
if (Number.isNaN(due.getTime())) return null;
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
String(value.getDate()).padStart(2, '0');
const dueDay = day(due);
const today = day(now);
if (dueDay < today) return { label: 'Overdue', priority: 2 };
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
return {
label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
priority: 4,
};
}
function buildMyWork(data, now = new Date()) {
const login = data.user?.login || '';
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
@ -11,15 +27,19 @@ function buildMyWork(data) {
);
const assigned = (item.assignees || []).includes(login);
const isReview = (item.work_reasons || []).includes('review_requested');
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
return {
...item,
key: (item.repository || 'unknown') + '#' + item.number,
is_review: isReview,
is_assigned: assigned,
has_update: false,
...(due ? { due_label: due.label } : {}),
reason: priorityLabel ? priorityLabel + ' priority' :
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')),
_priority: priorityLabel ? 0 : (isReview ? 2 : (assigned ? 3 : 4)),
(due && due.priority < 4 ? due.label :
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))),
_priority: priorityLabel ? 0 :
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
};
});
@ -358,6 +378,15 @@ function replaceIssueContent(data, repository, number, content) {
};
}
function replaceIssueDueDate(data, repository, number, dueDate) {
return {
...data,
issues: (data.issues || []).map(item =>
item.repository === repository && item.number === number ? { ...item, due_date: dueDate } : item
),
};
}
function removeIssue(data, repository, number) {
return {
...data,
@ -391,6 +420,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.replaceIssueContent = replaceIssueContent;
buildMyWork.replaceIssueDueDate = replaceIssueDueDate;
buildMyWork.removeIssue = removeIssue;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.countMyWork = countMyWork;

View File

@ -732,6 +732,39 @@ async def update_issue_labels(repository: str, number: int, label_ids: list[int]
}
async def update_assigned_issue_due_date(
repository: str, number: int, due_date: str | None
) -> dict:
path = f"repos/{repository}/issues/{number}"
login, issue = await _current_login_and_target(path)
if (
issue.get("state") != "open"
or isinstance(issue.get("pull_request"), dict)
or not _login_in_users(login, issue.get("assignees"))
):
raise IssueNotAvailableError("assigned issue not found")
payload = {"due_date": due_date} if due_date else {"unset_due_date": True}
response = await _get_client().patch(
f"/api/v1/{path}", headers=_auth(), json=payload
)
response.raise_for_status()
confirmed = response.json()
confirmed_due_date = confirmed.get("due_date") if isinstance(confirmed, dict) else None
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or confirmed_due_date != due_date
):
raise ValueError("Gitea did not confirm the issue due date update")
return {
"repository": repository,
"number": number,
"state": confirmed.get("state", "open"),
"due_date": confirmed_due_date,
}
async def issue_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/issues/{number}"
issue, comments = await asyncio.gather(
@ -756,6 +789,7 @@ async def issue_detail(repository: str, number: int) -> dict:
"state": issue.get("state", "") if isinstance(issue.get("state"), str) else "",
"body": issue.get("body", "") if isinstance(issue.get("body"), str) else "",
"updated_at": issue.get("updated_at", "") if isinstance(issue.get("updated_at"), str) else "",
"due_date": issue.get("due_date") if isinstance(issue.get("due_date"), str) else None,
"url": _safe_web_url(issue.get("html_url")),
"labels": [
label["name"] for label in labels

View File

@ -178,6 +178,14 @@ class IssueLabelUpdate(BaseModel):
label_ids: list[PositiveInt] = Field(max_length=20)
class IssueDueDateUpdate(BaseModel):
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,
)
class PullReviewComment(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
body: str = Field(min_length=1, max_length=10_000)
@ -286,7 +294,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)],
assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, dict)],
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
updated_at=i.get("updated_at") or "", url=i["html_url"],
updated_at=i.get("updated_at") or "",
due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None,
url=i["html_url"],
)
for i in (issues_data or [])[:50]
if isinstance(i, dict)
@ -324,7 +334,9 @@ def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
updated_at=item.get("updated_at") or "", url=item["html_url"],
updated_at=item.get("updated_at") or "",
due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None,
url=item["html_url"],
).model_dump()
for item in items
if all(field in item for field in ("id", "number", "title", "state", "html_url"))
@ -1126,6 +1138,32 @@ async def update_assigned_issue_content(
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/due-date")
async def update_assigned_issue_due_date(
update: IssueDueDateUpdate,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.update_assigned_issue_due_date(
repository, number, update.due_date
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
raise HTTPException(status_code=404, detail="Assigned issue not found")
except Exception:
return JSONResponse(
{"error": "The due date could not be updated. Your selection is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/labels")
async def repository_labels(owner: str, repo: str):
repository = f"{owner}/{repo}"

View File

@ -26,6 +26,7 @@ class Issue(BaseModel):
assignees: list[str] = []
repository: str = ""
updated_at: str = ""
due_date: str | None = None
url: str

View File

@ -421,6 +421,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
"assignees": [{"login": "timmy"}],
"repository": {"full_name": "stackchain/mobile"},
"updated_at": "2026-08-06T12:00:00Z",
"due_date": "2026-08-09T23:59:59Z",
"html_url": "https://forge.example/stackchain/mobile/issues/7",
}]
@ -448,6 +449,7 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
assert payload["issues"][0]["repository"] == "stackchain/mobile"
assert payload["issues"][0]["updated_at"] == "2026-08-06T12:00:00Z"
assert payload["issues"][0]["due_date"] == "2026-08-09T23:59:59Z"
assert payload["pull_requests"][0]["repository"] == "stackchain/api"
assert payload["pull_requests"][0]["updated_at"] == "2026-08-06T11:00:00Z"
assert payload["pull_requests"][0]["labels"] == ["priority-high"]

View File

@ -1,4 +1,5 @@
import asyncio
import json
import httpx
import pytest
@ -6,6 +7,87 @@ import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
@pytest.mark.parametrize(
("due_date", "expected_payload", "confirmed_due_date"),
[
("2026-08-09T23:59:59Z", {"due_date": "2026-08-09T23:59:59Z"}, "2026-08-09T23:59:59Z"),
(None, {"unset_due_date": True}, None),
],
)
async def test_gitea_due_date_update_revalidates_assignment_and_confirms_result(
due_date, expected_payload, confirmed_due_date
):
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.method == "GET":
return httpx.Response(200, json={
"number": 17, "state": "open", "pull_request": None,
"assignees": [{"login": "timmy"}],
})
return httpx.Response(200, json={
"number": 17, "state": "open", "due_date": confirmed_due_date,
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.update_assigned_issue_due_date(
"stackchain/api", 17, due_date
)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
]
assert json.loads(requests[2].content) == expected_payload
assert result == {
"repository": "stackchain/api", "number": 17,
"state": "open", "due_date": confirmed_due_date,
}
@pytest.mark.anyio
async def test_due_date_endpoint_sets_or_clears_assigned_issue(monkeypatch):
calls = []
async def update(repository, number, due_date):
calls.append((repository, number, due_date))
return {
"repository": repository, "number": number, "state": "open",
"due_date": due_date,
}
monkeypatch.setattr(
main.gitea_proxy, "update_assigned_issue_due_date", update, raising=False
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
set_response = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/due-date",
json={"due_date": "2026-08-09T23:59:59Z"},
)
clear_response = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/due-date",
json={"due_date": None},
)
assert [set_response.status_code, clear_response.status_code] == [200, 200]
assert set_response.headers["cache-control"] == "no-store"
assert set_response.json()["due_date"] == "2026-08-09T23:59:59Z"
assert clear_response.json()["due_date"] is None
assert calls == [
("stackchain/api", 17, "2026-08-09T23:59:59Z"),
("stackchain/api", 17, None),
]
@pytest.mark.anyio
async def test_edit_assigned_issue_updates_title_and_body_at_expected_revision(monkeypatch):
calls = []
@ -909,6 +991,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"state": "open",
"body": "Full issue context",
"updated_at": "2026-08-07T09:59:00Z",
"due_date": None,
"url": "https://forge.example/stackchain/api/issues/7",
"labels": ["P1"],
"assignees": ["timmy"],

View File

@ -78,6 +78,38 @@ process.stdout.write(JSON.stringify(queue));
assert queue[2]["reason"] == "Assigned to you"
def test_my_work_surfaces_and_ranks_due_issues_after_p0_before_ordinary_work():
payload = {
"user": {"login": "timmy"},
"issues": [
{"number": 1, "title": "Ordinary", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-07T12:00:00Z"},
{"number": 2, "title": "Due today", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-07T23:59:59Z"},
{"number": 3, "title": "Overdue", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z"},
{"number": 4, "title": "P0 future", "repository": "stackchain/api",
"labels": ["P0"], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
],
"pull_requests": [],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const queue = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'));
process.stdout.write(JSON.stringify(queue.map(item => ({{title:item.title,reason:item.reason,due_label:item.due_label}}))));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == [
{"title": "P0 future", "reason": "P0 priority", "due_label": "Due Aug 10"},
{"title": "Overdue", "reason": "Overdue", "due_label": "Overdue"},
{"title": "Due today", "reason": "Due today", "due_label": "Due today"},
{"title": "Ordinary", "reason": "Assigned to you"},
]
def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue():
payload = {
"user": {"login": "timmy"},
@ -234,6 +266,61 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
assert output["results"][0]["updated_at"] == "2026-08-07T10:01:00Z"
def test_issue_due_date_update_is_single_flight_and_keeps_draft_until_confirmed():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let calls = [];
let finish;
const controller = createIssueSheet({{
storage,
fetchJson:(url, options) => {{
calls.push({{url, options}});
return new Promise(resolve => {{ finish = resolve; }});
}},
}});
const item = {{repository:'stackchain/api', number:17}};
const first = controller.updateDueDate(item, '2026-08-09T23:59:59Z');
const duplicate = controller.updateDueDate(item, '2026-08-09T23:59:59Z');
const during = controller.loadDueDateDraft(item);
finish({{repository:'stackchain/api',number:17,state:'open',due_date:'2026-08-09T23:59:59Z'}});
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)}})),
same:first === duplicate, during, after:controller.loadDueDateDraft(item), results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/api/issues/17/due-date",
"method": "PATCH",
"body": {"due_date": "2026-08-09T23:59:59Z"},
}]
assert output["same"] is True
assert output["during"] == "2026-08-09T23:59:59Z"
assert output["after"] is None
assert output["results"][0]["due_date"] == "2026-08-09T23:59:59Z"
@pytest.mark.anyio
async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_badge():
html = await dashboard()
assert 'id="issue-due-date" type="date"' in html
assert 'id="save-issue-due-date"' in html
assert 'id="clear-issue-due-date"' in html
assert 'class="pill due-badge"' in html
assert '.issue-due-editor input, .issue-due-editor button { min-height:44px;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
def test_my_work_reviews_filter_and_summary_are_actionable():
items = [
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},

View File

@ -20,6 +20,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
"labels": [], "assignees": [{"login": "timmy"}],
"repository": {"full_name": "stackchain/api"},
"updated_at": "2026-08-07T10:00:00Z",
"due_date": "2026-08-09T23:59:59Z",
"html_url": "https://forge.example/stackchain/api/issues/51",
}],
}
@ -38,6 +39,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
"id": 51, "number": 51, "title": "Older issue", "state": "open",
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
"updated_at": "2026-08-07T10:00:00Z",
"due_date": "2026-08-09T23:59:59Z",
"url": "https://forge.example/stackchain/api/issues/51",
}],
}