feat: schedule assigned issues from mobile (#191)
This commit is contained in:
parent
5406ee2297
commit
c639b09aaf
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)) || '[]');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
42
src/main.py
42
src/main.py
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class Issue(BaseModel):
|
|||
assignees: list[str] = []
|
||||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
due_date: str | None = None
|
||||
url: str
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}],
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user