feat: file mobile issues without an owner (Closes #855)
This commit is contained in:
1 parent
fc35b17d24
commit
ed246ce6dd
12 files changed
+275
-63
No files matched your search
@@ -64,6 +64,20 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_preserves_explicit_no_owner():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'unowned-1',operationId:'unowned-1',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Backlog capture',body:'',labelIds:[],unassigned:true}};
|
||||
const calls=[];const store={{claimNext:async()=>item,complete:async()=>{{item=null;}},release:async()=>{{}},fail:async()=>{{}}}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push({{url,body:options.body||''}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:19,assignees:[]}};}};
|
||||
(async()=>{{await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(calls));}})();
|
||||
"""
|
||||
calls = run_node(script)
|
||||
assert json.loads(calls[1]["body"]) == {
|
||||
"title": "Backlog capture", "body": "", "label_ids": [], "unassigned": True
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
||||
@@ -769,6 +769,49 @@ async def test_create_issue_endpoint_validates_and_assigns_selected_initial_owne
|
||||
assert calls == [("stackchain/api", "Delegate at capture", "alex")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_endpoint_preserves_explicit_no_owner(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def access(repository):
|
||||
return {"full_name": repository}
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
calls.append((repository, title, assignee))
|
||||
return {"number": 19, "repository": repository, "assignees": []}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
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": "Backlog capture", "unassigned": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["assignees"] == []
|
||||
assert calls == [("stackchain/api", "Backlog capture", None)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_endpoint_rejects_named_and_no_owner_together():
|
||||
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": "Contradictory owner", "unassigned": True, "assignee": "alex"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "assignee" in response.text
|
||||
assert "unassigned" in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_initial_owner_candidates_require_repository_access_and_are_bounded(monkeypatch):
|
||||
async def access(repository):
|
||||
@@ -1290,6 +1333,33 @@ 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_omits_assignment_and_confirms_no_owner():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(201, json={
|
||||
"id": 83, "number": 19, "title": "Backlog capture", "state": "open",
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/19",
|
||||
"assignees": [], "labels": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.create_issue(
|
||||
"stackchain/api", "Backlog capture", "Context", None, []
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert json.loads(requests[0].content) == {
|
||||
"title": "Backlog capture", "body": "Context"
|
||||
}
|
||||
assert result["assignees"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_issue_requires_exact_selected_initial_owner():
|
||||
async def handler(_request):
|
||||
|
||||
@@ -99,6 +99,22 @@ process.stdout.write(JSON.stringify({{metadata:metadata.textContent}}));
|
||||
assert run_node(script)["metadata"].endswith("Owner: Alex (@alex)")
|
||||
|
||||
|
||||
def test_review_names_explicit_no_owner():
|
||||
script = f"""
|
||||
const createReview=require({json.dumps(str(MODULE))});
|
||||
function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}}
|
||||
const metadata=target(),intent=target();
|
||||
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),
|
||||
repository:target(),intent,title:target(),body:target(),metadata,
|
||||
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
|
||||
review.open({{draft:{{repository:'o/r',title:'Backlog capture',unassigned:true}},intent:'create-and-assign'}},target());
|
||||
process.stdout.write(JSON.stringify({{metadata:metadata.textContent,intent:intent.textContent}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["metadata"].endswith("Owner: No owner")
|
||||
assert output["intent"] == "Create unassigned"
|
||||
|
||||
|
||||
def test_review_names_the_repository_issue_type():
|
||||
script = f"""
|
||||
const createReview=require({json.dumps(str(MODULE))});
|
||||
|
||||
@@ -89,6 +89,24 @@ const queued=outbox.enqueue({{repository:'o/r',title:'Delegate',assignee:'alex',
|
||||
}]
|
||||
|
||||
|
||||
def test_issue_outbox_preserves_no_owner_and_delivers_unassigned_intent():
|
||||
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:()=>'unowned-outbox',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:19,assignees:[]}};}}}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Backlog capture',unassigned:true,completionIntent:'create-and-start'}});
|
||||
(async()=>{{await outbox.flush('timmy');process.stdout.write(JSON.stringify({{queued,calls}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["unassigned"] is True
|
||||
assert "completionIntent" not in output["queued"]
|
||||
assert output["calls"] == [{
|
||||
"title": "Backlog capture", "body": "", "label_ids": [], "unassigned": True
|
||||
}]
|
||||
|
||||
|
||||
def test_editing_queued_issue_can_return_initial_owner_to_self():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
@@ -105,6 +123,23 @@ process.stdout.write(JSON.stringify(updated));
|
||||
assert output["operationId"] != "1"
|
||||
|
||||
|
||||
def test_editing_queued_no_owner_to_self_rotates_delivery_and_allows_start():
|
||||
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:'Backlog',unassigned:true}});
|
||||
const updated=outbox.update(queued.id,{{...queued,unassigned:false,completionIntent:'create-and-start',estimateMinutes:30}});
|
||||
process.stdout.write(JSON.stringify({{queued,updated}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["queued"]["unassigned"] is True
|
||||
assert "unassigned" not in output["updated"]
|
||||
assert output["updated"]["completionIntent"] == "create-and-start"
|
||||
assert output["updated"]["estimateMinutes"] == 30
|
||||
assert output["updated"]["operationId"] != output["queued"]["operationId"]
|
||||
|
||||
|
||||
def test_create_and_start_estimate_survives_outbox_delivery_and_completion():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
|
||||
+49
-1
@@ -4072,6 +4072,25 @@ capture.saveDraft({{repository:'o/r',title:'Delegate',body:'Context',labelIds:[]
|
||||
assert json.loads(output["calls"][-1]["body"])["assignee"] == "alex"
|
||||
|
||||
|
||||
def test_issue_capture_persists_and_sends_explicit_no_owner():
|
||||
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 capture=createIssueCapture({{storage,createOperationId:()=>'unowned-op',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:19,assignees:[]}};}}}});
|
||||
capture.saveDraft({{repository:'o/r',title:'Backlog capture',body:'Context',labelIds:[],unassigned:true}});
|
||||
(async()=>{{const draft=capture.loadDraft();await capture.submit(draft);process.stdout.write(JSON.stringify({{draft,calls}}));}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["draft"]["unassigned"] is True
|
||||
assert output["calls"] == [{
|
||||
"title": "Backlog capture", "body": "Context", "label_ids": [], "unassigned": True
|
||||
}]
|
||||
|
||||
|
||||
def test_issue_capture_applies_and_switches_repository_templates_without_losing_authored_work():
|
||||
script = f"""
|
||||
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
@@ -4122,7 +4141,35 @@ const picker=createIssueCapture.createOwnerPicker(capture,documentRef,()=>{{}});
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
assert output == ["", "new"]
|
||||
assert output == ["", "__unassigned__", "new"]
|
||||
|
||||
|
||||
def test_initial_owner_picker_exposes_no_owner_and_disables_start():
|
||||
script = f"""
|
||||
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
function select(){{return{{value:'',dataset:{{}},children:[],selectedOptions:[],
|
||||
replaceChildren(){{this.children=[];this.value='';this.selectedOptions=[];}},
|
||||
appendChild(option){{this.children.push(option);}},addEventListener:()=>{{}}}};}}
|
||||
const owner=select(),status={{textContent:''}},repo={{value:'o/r'}};
|
||||
const submit={{disabled:false,textContent:''}},start={{disabled:false,title:''}};
|
||||
const documentRef={{querySelector:id=>({{
|
||||
'#create-issue-assignee':owner,'#create-issue-assignee-status':status,
|
||||
'#create-issue-repository':repo,'#submit-new-issue':submit,'#create-and-start-issue':start,
|
||||
}}[id]),createElement:()=>({{value:'',textContent:'',dataset:{{}}}})}};
|
||||
const picker=createIssueCapture.createOwnerPicker({{loadOwners:async()=>[]}},documentRef,()=>{{}});
|
||||
picker.reset('o/r',{{unassigned:true}});owner.value='__unassigned__';
|
||||
picker.updateActions(true,false,true);
|
||||
process.stdout.write(JSON.stringify({{options:owner.children.map(option=>[option.value,option.textContent]),fields:picker.fields(),submit,start}}));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["options"] == [["", "Me"], ["__unassigned__", "No owner"]]
|
||||
assert output["fields"] == {"assignee": "", "assigneeName": "", "unassigned": True}
|
||||
assert output["submit"]["textContent"] == "Create unassigned"
|
||||
assert output["start"]["disabled"] is True
|
||||
assert "No owner" in output["start"]["title"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -4146,6 +4193,7 @@ 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 '<option value="__unassigned__">No owner</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
|
||||
|
||||
Reference in new issue
Block a user