feat: choose initial owner during issue filing
CI / lint (pull_request) Successful in 1m48s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 18:26:33 +00:00
1 parent 322f025794
commit 6449ead83d
13 files changed
+383 -38

No files matched your search

+73
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):
+14
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))});
+34
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))});
+60 -5
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()