feat: file mobile issues without an owner (Closes #855)
CI / lint (pull_request) Successful in 1m50s
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 23:05:59 +00:00
1 parent fc35b17d24
commit ed246ce6dd
12 files changed
+275 -63

No files matched your search

+14
View File
@@ -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))});
+70
View File
@@ -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):
+16
View File
@@ -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))});
+35
View File
@@ -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
View File
@@ -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