feat: choose initial owner during issue filing
This commit is contained in:
1 parent
322f025794
commit
6449ead83d
13 files changed
+383
-38
No files matched your search
@@ -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):
|
||||
|
||||
@@ -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))});
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user