Merge pull request 'feat: Choose the initial owner while filing a mobile issue' (#844) from timmy/843-choose-the-initial-owner-while-filing-a-mobile-i into main
All checks were successful
CI / lint (push) Successful in 1m46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s

This commit is contained in:
timmy 2026-08-14 18:32:28 +00:00
commit 2ac6b1f56b
13 changed files with 383 additions and 38 deletions

View File

@ -446,6 +446,7 @@ function createBackgroundIssueSync({
title: item.title,
body: item.body,
label_ids: item.labelIds,
...(item.assignee ? { assignee: item.assignee } : {}),
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),

View File

@ -19,6 +19,90 @@ function normalizeSharedContent(value = {}) {
return { title, body };
}
function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
const select = documentRef.querySelector('#create-issue-assignee');
const status = documentRef.querySelector('#create-issue-assignee-status');
const getRepository = () => documentRef.querySelector('#create-issue-repository').value;
const loadOwners = repository => issueCapture.loadOwners(repository);
let ownerRequest = 0;
function reset(repository, selected = {}) {
select.replaceChildren();
const me = documentRef.createElement('option');
me.value = '';
me.textContent = 'Me';
select.appendChild(me);
if (selected.assignee) {
const option = documentRef.createElement('option');
option.value = selected.assignee;
option.textContent = (selected.assigneeName || selected.assignee) + ' (@' + selected.assignee + ')';
option.dataset.name = selected.assigneeName || selected.assignee;
select.appendChild(option);
select.value = selected.assignee;
}
select.dataset.repository = '';
status.textContent = repository ? 'Open the owner picker to load eligible teammates.' : 'Choose a repository first.';
}
async function load(repository) {
if (!repository || select.dataset.repository === repository) return;
const request = ++ownerRequest;
const selected = select.value;
const selectedName = select.selectedOptions?.[0]?.dataset.name || '';
status.textContent = 'Loading eligible teammates…';
try {
const owners = await loadOwners(repository);
if (request !== ownerRequest || getRepository() !== repository) return;
const selectedStillEligible = owners.some(owner => owner?.login === selected);
reset(repository, selectedStillEligible ? {assignee:selected, assigneeName:selectedName} : {});
owners.forEach(owner => {
if (!owner?.login || owner.login === selected) return;
const option = documentRef.createElement('option');
option.value = owner.login;
option.textContent = (owner.name || owner.login) + ' (@' + owner.login + ')';
option.dataset.name = owner.name || owner.login;
select.appendChild(option);
});
select.dataset.repository = repository;
status.textContent = owners.length ? 'Choose yourself or an eligible teammate.' : 'No eligible teammates are available.';
} catch (_error) {
if (request !== ownerRequest || getRepository() !== repository) return;
status.textContent = 'Teammates could not be loaded. The issue will stay assigned to you.';
}
}
function updateActions(hasRepository, hasBlockers, canStart) {
const submit = documentRef.querySelector('#submit-new-issue');
const start = documentRef.querySelector('#create-and-start-issue');
const hasTeammateOwner = Boolean(select.value);
submit.disabled = !hasRepository;
submit.textContent = hasTeammateOwner ? 'Create & assign' : 'Create & assign to me';
start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || !canStart;
start.title = hasBlockers ? 'Blocked work cannot start until its blockers are complete.' :
(hasTeammateOwner ? 'Work assigned to a teammate cannot be added to your Today queue.' : '');
}
function fields() {
return {
assignee: select.value,
assigneeName: select.selectedOptions?.[0]?.dataset.name || '',
};
}
function draft(labelIds, blockers, trim = false) {
const value = id => documentRef.querySelector(id).value;
const clean = input => trim ? input.trim() : input;
return {
repository:value('#create-issue-repository'),
title:clean(value('#create-issue-title')), body:clean(value('#create-issue-body')),
labelIds, milestoneId:Number(value('#create-issue-milestone')) || null,
dueDate:value('#create-issue-due-date'), ...fields(), blockers,
};
}
select.addEventListener('focus', () => {
const repository = getRepository();
if (repository) load(repository);
});
select.addEventListener('change', onChange);
return { reset, load, updateActions, fields, draft };
}
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
const storageKey = 'stackchain.issue-capture.v1';
const sharedStorageKey = 'stackchain.issue-share.v1';
@ -51,6 +135,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
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) : '';
const safeAssignee = value => /^[A-Za-z0-9_.-]+$/.test(String(value || '')) ? String(value) : '';
function loadStored() {
try {
@ -63,6 +148,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
labelIds: safeLabelIds(parsed.labelIds),
operationId: String(parsed.operationId || '').slice(0, 128),
};
const assignee = safeAssignee(parsed.assignee);
if (assignee) {
draft.assignee = assignee;
draft.assigneeName = String(parsed.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
const milestoneId = safeMilestoneId(parsed.milestoneId);
const dueDate = safeDueDate(parsed.dueDate);
if (milestoneId !== null) draft.milestoneId = milestoneId;
@ -88,13 +178,18 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
body: String(draft?.body || ''),
labelIds: safeLabelIds(draft?.labelIds),
};
const assignee = safeAssignee(draft?.assignee);
if (assignee) {
safe.assignee = assignee;
safe.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
const milestoneId = safeMilestoneId(draft?.milestoneId);
const dueDate = safeDueDate(draft?.dueDate);
if (milestoneId !== null) safe.milestoneId = milestoneId;
if (dueDate) safe.dueDate = dueDate;
const blockers = safeBlockers(draft?.blockers);
if (blockers.length) safe.blockers = blockers;
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName']
.every(key => (previous[key] || '') === (safe[key] || '')) &&
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) &&
JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []);
@ -203,6 +298,13 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
);
}
function loadOwners(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
return fetchJson('api/v1/repos/' + encoded + '/issue-assignees').then(owners =>
Array.isArray(owners) ? owners : []
);
}
function loadRepositoryPage(page) {
const safePage = Math.max(1, Math.floor(Number(page) || 1));
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
@ -312,6 +414,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
},
body: JSON.stringify({
title: saved.title, body: saved.body, label_ids: saved.labelIds,
...(saved.assignee ? {assignee: saved.assignee} : {}),
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
}),
@ -323,7 +426,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadRepositoryPage,
searchRepositories, searchBlockers, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
@ -332,5 +435,6 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;

View File

@ -682,6 +682,8 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-blocker-selected button { min-height:44px; }
.create-issue-form label { display:grid; gap:6px; }
.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-owner { min-width:0; }
.create-issue-owner select { min-height:44px; width:100%; }
.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-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; }

View File

@ -519,6 +519,7 @@
loadMilestones: item => issueController.loadMilestones(item),
});
let issueCapture = null;
let issueOwnerPicker = null;
let updateFollowUp = null;
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
const unfiledCaptures = createUnfiledCaptures({
@ -642,6 +643,8 @@
}, () => {
if (!issueCapture) {
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
}
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
@ -3675,28 +3678,13 @@
let issueCaptureBlockers = [];
function saveIssueCaptureDraft() {
if (!issueCapture) return;
issueCapture.saveDraft({
repository: qs('#create-issue-repository').value,
title: qs('#create-issue-title').value,
body: qs('#create-issue-body').value,
labelIds: selectedIssueLabelIds(),
milestoneId: Number(qs('#create-issue-milestone').value) || null,
dueDate: qs('#create-issue-due-date').value,
blockers: issueCaptureBlockers,
});
if (issueCapture) issueCapture.saveDraft(
issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers)
);
}
function currentIssueCaptureDraft() {
return {
repository: qs('#create-issue-repository').value,
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
labelIds: selectedIssueLabelIds(),
milestoneId: Number(qs('#create-issue-milestone').value) || null,
dueDate: qs('#create-issue-due-date').value,
blockers: issueCaptureBlockers,
};
return issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, true);
}
let issueCaptureRepositories = [];
@ -3723,11 +3711,8 @@
}
function updateIssueCreateActions() {
const hasRepository = Boolean(qs('#create-issue-repository').value);
const hasBlockers = issueCaptureBlockers.length > 0;
qs('#submit-new-issue').disabled = !hasRepository;
qs('#create-and-start-issue').disabled = !hasRepository || hasBlockers || !createAndStart.available();
qs('#create-and-start-issue').title = hasBlockers ? 'Blocked work cannot start until its blockers are complete.' : '';
issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value),
issueCaptureBlockers.length > 0, createAndStart.available());
}
function renderIssueCaptureBlockers(blockers) {
@ -3801,6 +3786,7 @@
qs('#create-issue-repository-search').value = repository;
qs('#create-issue-repository-results').hidden = true;
qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.';
issueOwnerPicker.reset(repository);
loadIssueLabels(repository);
loadIssueMilestones(repository);
saveIssueCaptureDraft();
@ -3953,6 +3939,7 @@
setIssueFilingMode(Boolean(captureDraft.repository));
qs('#create-issue-capture-status').textContent = '';
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
issueOwnerPicker.reset(captureDraft.repository, captureDraft);
renderIssueCaptureBlockers(captureDraft.blockers || []);
qs('#create-issue-blocker-search').value = '';
qs('#create-issue-blocker-results').hidden = true;
@ -5144,6 +5131,7 @@
})
);
qs('#create-issue-repository').addEventListener('change', event => {
issueOwnerPicker.reset(event.target.value);
loadIssueLabels(event.target.value);
loadIssueMilestones(event.target.value);
qs('#create-issue-repository-search').value = event.target.value;

View File

@ -797,6 +797,12 @@
</div>
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
<label class="create-issue-owner" for="create-issue-assignee">Owner
<select id="create-issue-assignee" aria-describedby="create-issue-assignee-status">
<option value="">Me</option>
</select>
<span id="create-issue-assignee-status" class="small" aria-live="polite">Open the owner picker to load eligible teammates.</span>
</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>

View File

@ -103,11 +103,14 @@
const labels = (draft.labels || []).map(label => typeof label === 'string' ? label : label.name);
const milestone = draft.milestone?.title || draft.milestoneTitle || 'No milestone';
const dueDate = draft.dueDate || draft.due_date || 'No due date';
const owner = draft.assignee
? 'Owner: ' + (draft.assigneeName || draft.assignee) + ' (@' + draft.assignee + ')'
: 'Assigned to you';
options.metadata.textContent = [
labels.length ? 'Labels: ' + labels.join(', ') : 'No labels',
'Milestone: ' + milestone,
'Due: ' + dueDate,
'Assigned to you',
owner,
].join(' · ');
if (options.blockerList) {
options.blockerList.replaceChildren(...(draft.blockers || []).map(blocker => {

View File

@ -83,9 +83,15 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
status: 'queued',
queuedAt: Number(now()),
};
const assignee = /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
? String(draft.assignee) : '';
if (assignee) {
item.assignee = assignee;
item.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
}
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
if (draft?.completionIntent === 'create-and-start' && !assignee) item.completionIntent = 'create-and-start';
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
const attachments = captureAttachments(draft?.attachments);
@ -192,6 +198,11 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const nextTitle = String(draft?.title || '');
const nextBody = String(draft?.body || '');
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
const nextAssignee = /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
? String(draft.assignee) : undefined;
const nextAssigneeName = nextAssignee
? String(draft?.assigneeName || nextAssignee).replace(/\s+/g, ' ').trim().slice(0, 255)
: undefined;
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
? Number(draft.milestoneId) : undefined;
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
@ -203,19 +214,25 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
JSON.stringify(item.attachments || null) !== JSON.stringify(nextAttachments || null);
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.assignee !== nextAssignee || item.assigneeName !== nextAssigneeName
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(nextBlockers || null);
updated = {
...item,
repository: nextRepository, title: nextTitle,
body: nextBody, labelIds: nextLabelIds,
assignee: nextAssignee, assigneeName: nextAssigneeName,
milestoneId: nextMilestoneId, dueDate: nextDueDate,
attachment: nextAttachment, attachments:nextAttachments, blockers:nextBlockers,
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued',
};
if (draft?.completionIntent === 'create-and-start') updated.completionIntent = 'create-and-start';
if (draft?.completionIntent === 'create-and-start' && !nextAssignee) updated.completionIntent = 'create-and-start';
else delete updated.completionIntent;
if (nextAssignee === undefined) {
delete updated.assignee;
delete updated.assigneeName;
}
if (nextMilestoneId === undefined) delete updated.milestoneId;
if (nextDueDate === undefined) delete updated.dueDate;
if (nextAttachment === undefined) delete updated.attachment;
@ -305,6 +322,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.assignee ? { assignee: item.assignee } : {}),
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),

View File

@ -1452,8 +1452,10 @@ async def create_issue(
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")
if confirmed_assignees != [assignee]:
raise ValueError(
"Gitea did not confirm self-assignment or exact issue assignment"
)
labels_value = issue.get("labels")
labels = labels_value if isinstance(labels_value, list) else []
milestone_value = issue.get("milestone")

View File

@ -714,6 +714,9 @@ def _validate_binary_attachment(filename: str, content_type: str, content: bytes
class IssueCreation(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
assignee: str | None = Field(
default=None, pattern=r"^[A-Za-z0-9_.-]+$", max_length=255
)
label_ids: list[int] = Field(default_factory=list, max_length=20)
milestone_id: PositiveInt | None = None
due_date: str | None = Field(
@ -4525,6 +4528,36 @@ async def repository_labels(owner: str, repo: str):
)
@app.get("/api/v1/repos/{owner}/{repo}/issue-assignees")
async def repository_issue_assignees(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_assignees():
if await gitea_proxy.repository_access(repository) is None:
raise HTTPException(status_code=404, detail="Repository not found")
candidates = [
item
for item in await gitea_proxy.issue_handoff_candidates(repository)
if isinstance(item, dict)
and isinstance(item.get("login"), str)
and re.fullmatch(r"[A-Za-z0-9_.-]+", item["login"])
][:25]
return JSONResponse(candidates, headers={"Cache-Control": "no-store"})
try:
return await asyncio.wait_for(
load_assignees(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Owners could not be loaded. You can still assign the issue to yourself."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201)
async def create_assigned_issue(
creation: IssueCreation,
@ -4541,6 +4574,17 @@ async def create_assigned_issue(
login = user.get("login") if isinstance(user, dict) else None
if not login or accessible is None:
raise HTTPException(status_code=404, detail="Repository not found")
assignee = login
if creation.assignee and creation.assignee != login:
eligible = {
item["login"]
for item in await gitea_proxy.issue_handoff_candidates(repository)
}
if creation.assignee not in eligible:
raise HTTPException(
status_code=422, detail="Selected owner is no longer eligible"
)
assignee = creation.assignee
if creation.label_ids:
available_labels = await gitea_proxy.repo_labels(repository)
valid_label_ids = {
@ -4566,13 +4610,13 @@ async def create_assigned_issue(
repository,
creation.title,
creation.body,
login,
assignee,
creation.label_ids,
creation.milestone_id,
creation.due_date,
)
return await gitea_proxy.create_issue(
repository, creation.title, creation.body, login, creation.label_ids
repository, creation.title, creation.body, assignee, creation.label_ids
)
try:
@ -4584,6 +4628,7 @@ async def create_assigned_issue(
repository,
creation.title,
creation.body,
creation.assignee,
tuple(creation.label_ids),
creation.milestone_id,
creation.due_date,

View File

@ -620,6 +620,63 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
@pytest.mark.anyio
async def test_create_issue_endpoint_validates_and_assigns_selected_initial_owner(monkeypatch):
calls = []
async def user():
return {"login": "timmy"}
async def access(repository):
return {"full_name": repository}
async def candidates(repository):
assert repository == "stackchain/api"
return [{"login": "alex", "name": "Alex"}]
async def create(repository, title, body, assignee, label_ids):
calls.append((repository, title, assignee))
return {"number": 18, "repository": repository, "assignees": [assignee]}
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
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": "Delegate at capture", "assignee": "alex"},
)
assert response.status_code == 201
assert response.json()["assignees"] == ["alex"]
assert calls == [("stackchain/api", "Delegate at capture", "alex")]
@pytest.mark.anyio
async def test_initial_owner_candidates_require_repository_access_and_are_bounded(monkeypatch):
async def access(repository):
return {"full_name": repository} if repository == "stackchain/api" else None
async def candidates(repository):
return ([{"login": "bad user", "name": "Unsafe"}] +
[{"login": f"user-{index}", "name": f"User {index}"} for index in range(40)])
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
listed = await client.get("/api/v1/repos/stackchain/api/issue-assignees")
missing = await client.get("/api/v1/repos/stackchain/private/issue-assignees")
assert listed.status_code == 200
assert listed.headers["cache-control"] == "no-store"
assert len(listed.json()) == 25
assert all(item["login"] != "bad user" for item in listed.json())
assert missing.status_code == 404
@pytest.mark.anyio
async def test_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
calls = []
@ -1118,6 +1175,22 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
assert result["labels"] == ["P0"]
@pytest.mark.anyio
async def test_gitea_create_issue_requires_exact_selected_initial_owner():
async def handler(_request):
return httpx.Response(201, json={
"id": 82, "number": 18, "title": "Delegate", "state": "open",
"assignees": [{"login": "alex"}, {"login": "timmy"}], "labels": [],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(ValueError, match="exact issue assignment"):
await gitea_proxy.create_issue("stackchain/api", "Delegate", "", "alex", [])
finally:
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_gitea_create_issue_requires_confirmed_release_plan():
async def handler(_request):

View File

@ -85,6 +85,20 @@ process.stdout.write(JSON.stringify(blockerList.children.map(item=>item.textCont
assert run_node(script) == ["o/api #7 — API ready", "o/web #8 — Web ready"]
def test_review_names_the_selected_initial_owner():
script = f"""
const createReview=require({json.dumps(str(MODULE))});
function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}}
const metadata=target();
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),
repository:target(),intent:target(),title:target(),body:target(),metadata,
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
review.open({{draft:{{repository:'o/r',title:'Delegate',assignee:'alex',assigneeName:'Alex'}},intent:'create-and-assign'}},target());
process.stdout.write(JSON.stringify({{metadata:metadata.textContent}}));
"""
assert run_node(script)["metadata"].endswith("Owner: Alex (@alex)")
def test_escape_returns_to_the_unchanged_issue_form():
script = f"""
const createReview = require({json.dumps(str(MODULE))});

View File

@ -71,6 +71,40 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
assert output["stored"]["version"] == 3
def test_issue_outbox_preserves_initial_owner_and_disables_start_for_teammate():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'owner-outbox',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:18,assignees:['alex']}};}}}});
const queued=outbox.enqueue({{repository:'o/r',title:'Delegate',assignee:'alex',assigneeName:'Alex',completionIntent:'create-and-start'}});
(async()=>{{await outbox.flush('timmy');process.stdout.write(JSON.stringify({{queued,calls}}));}})();
"""
output = run_node(script)
assert output["queued"]["assignee"] == "alex"
assert output["queued"]["assigneeName"] == "Alex"
assert "completionIntent" not in output["queued"]
assert output["calls"] == [{
"title": "Delegate", "body": "", "label_ids": [], "assignee": "alex"
}]
def test_editing_queued_issue_can_return_initial_owner_to_self():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let id=0;const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>String(++id)}});
const queued=outbox.enqueue({{repository:'o/r',title:'Delegate',assignee:'alex',assigneeName:'Alex'}});
const updated=outbox.update(queued.id,{{...queued,assignee:'',assigneeName:'',completionIntent:'create-and-start'}});
process.stdout.write(JSON.stringify(updated));
"""
output = run_node(script)
assert "assignee" not in output
assert "assigneeName" not in output
assert output["completionIntent"] == "create-and-start"
assert output["operationId"] != "1"
def test_issue_outbox_preserves_bounded_ordered_evidence_bundle():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});

View File

@ -1845,8 +1845,7 @@ async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
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
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
@pytest.mark.anyio
@ -4018,7 +4017,7 @@ async def test_mobile_issue_capture_requires_explicit_searchable_repository_sele
assert "if (query.length < 2) {\n issueCapture.searchRepositories(query);" in html
assert "renderIssueRepositoryResults(state.items)" in html
assert "selectIssueCaptureRepository(repository)" in html
assert "const hasRepository = Boolean(qs('#create-issue-repository').value);" in html
assert "issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value)" in html
assert '.create-issue-repository-result { min-height:44px;' in html
assert '.create-issue-repository-picker { min-width:0;' in html
@ -4053,6 +4052,49 @@ capture.saveDraft({{repository:'o/r',title:'Blocked work',body:'',blockers}});
assert output["calls"] == ["api/v1/search?q=api&limit=20"]
def test_issue_capture_persists_selected_owner_and_sends_it_on_creation():
script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values=new Map(); const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const fetchJson=async (url,options={{}})=>{{calls.push({{url,body:options.body||''}});if(url.endsWith('/issue-assignees'))return [{{login:'alex',name:'Alex'}}];return {{number:18,assignees:['alex']}};}};
const capture=createIssueCapture({{storage,fetchJson,createOperationId:()=>'owner-op'}});
capture.saveDraft({{repository:'o/r',title:'Delegate',body:'Context',labelIds:[],assignee:'alex',assigneeName:'Alex'}});
(async()=>{{const owners=await capture.loadOwners('o/r');const draft=capture.loadDraft();await capture.submit(draft);process.stdout.write(JSON.stringify({{owners,draft,calls}}));}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["owners"] == [{"login": "alex", "name": "Alex"}]
assert output["draft"]["assignee"] == "alex"
assert output["draft"]["assigneeName"] == "Alex"
assert json.loads(output["calls"][-1]["body"])["assignee"] == "alex"
def test_initial_owner_picker_discards_an_older_same_repository_response():
script = f"""
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
let repository='o/a'; const pending=[];
function select(){{const listeners={{}};return{{value:'',dataset:{{}},children:[],selectedOptions:[],
replaceChildren(){{this.children=[];this.value='';this.selectedOptions=[];}},appendChild(option){{this.children.push(option);if(option.value===this.value)this.selectedOptions=[option];}},
addEventListener:(name,fn)=>listeners[name]=fn}};}}
const owner=select(),status={{textContent:''}},repo={{get value(){{return repository;}}}};
const documentRef={{querySelector:id=>id==='#create-issue-assignee'?owner:id==='#create-issue-assignee-status'?status:repo,
createElement:()=>({{value:'',textContent:'',dataset:{{}}}})}};
const capture={{loadOwners:()=>new Promise(resolve=>pending.push(resolve))}};
const picker=createIssueCapture.createOwnerPicker(capture,documentRef,()=>{{}});
(async()=>{{picker.reset('o/a');const first=picker.load('o/a');repository='o/b';picker.reset('o/b');
repository='o/a';picker.reset('o/a');const latest=picker.load('o/a');pending[1]([{{login:'new',name:'New'}}]);await latest;
pending[0]([{{login:'old',name:'Old'}}]);await first;
process.stdout.write(JSON.stringify(owner.children.map(option=>option.value)));}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output == ["", "new"]
@pytest.mark.anyio
async def test_mobile_issue_capture_selects_blockers_without_starting_blocked_work():
html = await dashboard()
@ -4061,14 +4103,27 @@ async def test_mobile_issue_capture_selects_blockers_without_starting_blocked_wo
assert 'id="create-issue-blocker-selected"' in html
assert 'id="create-issue-blocker-status" class="small" aria-live="polite"' in html
assert "issueCapture.searchBlockers(query)" in html
assert "blockers: issueCaptureBlockers" in html
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
assert "renderIssueCaptureBlockers(captureDraft.blockers || [])" in html
assert "const hasBlockers = issueCaptureBlockers.length > 0;" in html
assert "issueCaptureBlockers.length > 0, createAndStart.available()" in html
assert ".create-issue-blocker-result" in html
assert "min-height:44px" in html.split(".create-issue-blocker-result", 1)[1]
assert "@media(max-width:320px)" in html
@pytest.mark.anyio
async def test_mobile_issue_capture_lazily_selects_an_initial_owner():
html = await dashboard()
assert 'id="create-issue-assignee"' in html
assert '<option value="">Me</option>' in html
assert 'id="create-issue-assignee-status" class="small" aria-live="polite"' in html
assert "createIssueCapture.createOwnerPicker(issueCapture, document," in html
assert "() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });" in html
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
assert "issueOwnerPicker.updateActions(" in html
assert '.create-issue-owner select { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
html = await dashboard()