Merge pull request 'File mobile issues without assigning an owner' (#856) from timmy/855-file-mobile-issues-without-owner into main
This commit is contained in:
commit
631eb2e584
|
|
@ -446,6 +446,7 @@ function createBackgroundIssueSync({
|
|||
title: item.title,
|
||||
body: item.body,
|
||||
label_ids: item.labelIds,
|
||||
...(item.unassigned ? { unassigned: true } : {}),
|
||||
...(item.assignee ? { assignee: item.assignee } : {}),
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ function normalizeSharedContent(value = {}) {
|
|||
}
|
||||
|
||||
function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
|
||||
const NO_OWNER = '__unassigned__';
|
||||
const select = documentRef.querySelector('#create-issue-assignee');
|
||||
const status = documentRef.querySelector('#create-issue-assignee-status');
|
||||
const getRepository = () => documentRef.querySelector('#create-issue-repository').value;
|
||||
|
|
@ -31,6 +32,11 @@ function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
|
|||
me.value = '';
|
||||
me.textContent = 'Me';
|
||||
select.appendChild(me);
|
||||
const noOwner = documentRef.createElement('option');
|
||||
noOwner.value = NO_OWNER;
|
||||
noOwner.textContent = 'No owner';
|
||||
select.appendChild(noOwner);
|
||||
if (selected.unassigned === true) select.value = NO_OWNER;
|
||||
if (selected.assignee) {
|
||||
const option = documentRef.createElement('option');
|
||||
option.value = selected.assignee;
|
||||
|
|
@ -53,7 +59,8 @@ function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
|
|||
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} : {});
|
||||
reset(repository, selected === NO_OWNER ? {unassigned:true} :
|
||||
(selectedStillEligible ? {assignee:selected, assigneeName:selectedName} : {}));
|
||||
owners.forEach(owner => {
|
||||
if (!owner?.login || owner.login === selected) return;
|
||||
const option = documentRef.createElement('option');
|
||||
|
|
@ -63,7 +70,8 @@ function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
|
|||
select.appendChild(option);
|
||||
});
|
||||
select.dataset.repository = repository;
|
||||
status.textContent = owners.length ? 'Choose yourself or an eligible teammate.' : 'No eligible teammates are available.';
|
||||
status.textContent = owners.length ? 'Choose yourself, no owner, or an eligible teammate.' :
|
||||
'Choose yourself or no owner; 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.';
|
||||
|
|
@ -72,17 +80,21 @@ function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
|
|||
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);
|
||||
const hasNoOwner = select.value === NO_OWNER;
|
||||
const hasTeammateOwner = Boolean(select.value) && !hasNoOwner;
|
||||
submit.disabled = !hasRepository;
|
||||
submit.textContent = hasTeammateOwner ? 'Create & assign' : 'Create & assign to me';
|
||||
start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || !canStart;
|
||||
submit.textContent = hasNoOwner ? 'Create unassigned' :
|
||||
(hasTeammateOwner ? 'Create & assign' : 'Create & assign to me');
|
||||
start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || hasNoOwner || !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.' : '');
|
||||
(hasNoOwner ? 'No owner work cannot be added to your Today queue.' :
|
||||
(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 || '',
|
||||
assignee: select.value === NO_OWNER ? '' : select.value,
|
||||
assigneeName: select.value === NO_OWNER ? '' : (select.selectedOptions?.[0]?.dataset.name || ''),
|
||||
unassigned: select.value === NO_OWNER,
|
||||
};
|
||||
}
|
||||
function draft(labelIds, blockers, trim = false) {
|
||||
|
|
@ -152,6 +164,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
|||
labelIds: safeLabelIds(parsed.labelIds),
|
||||
operationId: String(parsed.operationId || '').slice(0, 128),
|
||||
};
|
||||
if (parsed.unassigned === true) draft.unassigned = true;
|
||||
if (typeof parsed.templateName === 'string' && parsed.templateName.trim()) {
|
||||
draft.templateName = parsed.templateName.trim().slice(0, 80);
|
||||
draft.templateId = String(parsed.templateId || '').slice(0, 80);
|
||||
|
|
@ -187,12 +200,13 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
|||
body: String(draft?.body || ''),
|
||||
labelIds: safeLabelIds(draft?.labelIds),
|
||||
};
|
||||
if (draft?.unassigned === true) safe.unassigned = true;
|
||||
if (typeof draft?.templateName === 'string' && draft.templateName.trim()) {
|
||||
safe.templateName = draft.templateName.trim().slice(0, 80);
|
||||
safe.templateId = String(draft.templateId || '').slice(0, 80);
|
||||
safe.capturedBody = String(draft.capturedBody || '').slice(0, 10000);
|
||||
}
|
||||
const assignee = safeAssignee(draft?.assignee);
|
||||
const assignee = safe.unassigned ? '' : safeAssignee(draft?.assignee);
|
||||
if (assignee) {
|
||||
safe.assignee = assignee;
|
||||
safe.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
|
|
@ -203,7 +217,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
|||
if (dueDate) safe.dueDate = dueDate;
|
||||
const blockers = safeBlockers(draft?.blockers);
|
||||
if (blockers.length) safe.blockers = blockers;
|
||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName',
|
||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName', 'unassigned',
|
||||
'templateName', 'templateId', 'capturedBody']
|
||||
.every(key => (previous[key] || '') === (safe[key] || '')) &&
|
||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) &&
|
||||
|
|
@ -677,6 +691,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
|||
},
|
||||
body: JSON.stringify({
|
||||
title: saved.title, body: saved.body, label_ids: saved.labelIds,
|
||||
...(saved.unassigned ? {unassigned:true} : {}),
|
||||
...(saved.assignee ? {assignee: saved.assignee} : {}),
|
||||
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
|
||||
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
|
||||
|
|
|
|||
|
|
@ -806,6 +806,7 @@
|
|||
<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>
|
||||
<option value="__unassigned__">No owner</option>
|
||||
</select>
|
||||
<span id="create-issue-assignee-status" class="small" aria-live="polite">Open the owner picker to load eligible teammates.</span>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -97,16 +97,17 @@
|
|||
function render(payload) {
|
||||
const draft = payload.draft;
|
||||
options.repository.textContent = draft.repository || 'No repository';
|
||||
options.intent.textContent = INTENT_LABELS[payload.intent] || payload.intent;
|
||||
options.intent.textContent = draft.unassigned === true ? 'Create unassigned' :
|
||||
(INTENT_LABELS[payload.intent] || payload.intent);
|
||||
if (options.issueType) options.issueType.textContent = draft.templateName || 'Blank issue';
|
||||
options.title.textContent = draft.title;
|
||||
options.body.textContent = draft.body || 'No note provided.';
|
||||
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
|
||||
const owner = draft.unassigned === true ? 'Owner: No owner' : (draft.assignee
|
||||
? 'Owner: ' + (draft.assigneeName || draft.assignee) + ' (@' + draft.assignee + ')'
|
||||
: 'Assigned to you';
|
||||
: 'Assigned to you');
|
||||
const planning = [];
|
||||
if (Number.isInteger(draft.estimateMinutes)) {
|
||||
planning.push('Estimate: ' + draft.estimateMinutes + ' min');
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
|||
return blockers.length ? blockers : undefined;
|
||||
}
|
||||
|
||||
function captureEstimate(value) {
|
||||
const estimate = Number(value);
|
||||
return Number.isInteger(estimate) && estimate >= 5 && estimate <= 1440 ? estimate : undefined;
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
|
|
@ -89,14 +94,12 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
|||
item.assignee = assignee;
|
||||
item.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
if (draft?.unassigned === true) item.unassigned = true;
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
|
||||
if (draft?.completionIntent === 'create-and-start' && !assignee) {
|
||||
if (draft?.completionIntent === 'create-and-start' && !assignee && !item.unassigned) {
|
||||
item.completionIntent = 'create-and-start';
|
||||
const estimateMinutes = Number(draft?.estimateMinutes);
|
||||
if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) {
|
||||
item.estimateMinutes = estimateMinutes;
|
||||
}
|
||||
item.estimateMinutes = captureEstimate(draft?.estimateMinutes);
|
||||
}
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
if (attachment) item.attachment = attachment;
|
||||
|
|
@ -200,62 +203,58 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
|||
let updated = null;
|
||||
const items = read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const nextRepository = String(draft?.repository || '');
|
||||
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 || ''))
|
||||
const repository = String(draft?.repository || '');
|
||||
const title = String(draft?.title || '');
|
||||
const body = String(draft?.body || '');
|
||||
const labelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
||||
const unassigned = draft?.unassigned === true;
|
||||
const assignee = !unassigned && /^[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)
|
||||
const assigneeName = assignee
|
||||
? String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255)
|
||||
: undefined;
|
||||
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
|
||||
const milestoneId = 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 || ''))
|
||||
const dueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||
? String(draft.dueDate) : undefined;
|
||||
const nextAttachment = captureAttachment(draft?.attachment);
|
||||
const nextAttachments = captureAttachments(draft?.attachments);
|
||||
const nextBlockers = captureBlockers(draft?.blockers);
|
||||
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null) ||
|
||||
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
|
||||
|| item.estimateMinutes !== (() => {
|
||||
const value = Number(draft?.estimateMinutes);
|
||||
return Number.isInteger(value) && value >= 5 && value <= 1440 ? value : undefined;
|
||||
})()
|
||||
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(nextBlockers || null);
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
const attachments = captureAttachments(draft?.attachments);
|
||||
const blockers = captureBlockers(draft?.blockers);
|
||||
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(attachment || null) ||
|
||||
JSON.stringify(item.attachments || null) !== JSON.stringify(attachments || null);
|
||||
const changed = item.repository !== repository || item.title !== title || item.body !== body
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(labelIds)
|
||||
|| Boolean(item.unassigned) !== unassigned
|
||||
|| item.assignee !== assignee || item.assigneeName !== assigneeName
|
||||
|| item.milestoneId !== milestoneId || item.dueDate !== dueDate
|
||||
|| item.estimateMinutes !== captureEstimate(draft?.estimateMinutes)
|
||||
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(blockers || 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,
|
||||
repository, title, body, labelIds,
|
||||
unassigned: unassigned || undefined,
|
||||
assignee, assigneeName, milestoneId, dueDate,
|
||||
attachment, attachments, blockers,
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
if (draft?.completionIntent === 'create-and-start' && !nextAssignee) {
|
||||
if (draft?.completionIntent === 'create-and-start' && !assignee && !unassigned) {
|
||||
updated.completionIntent = 'create-and-start';
|
||||
const estimateMinutes = Number(draft?.estimateMinutes);
|
||||
if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) {
|
||||
updated.estimateMinutes = estimateMinutes;
|
||||
} else delete updated.estimateMinutes;
|
||||
updated.estimateMinutes = captureEstimate(draft?.estimateMinutes);
|
||||
} else {
|
||||
delete updated.completionIntent;
|
||||
delete updated.estimateMinutes;
|
||||
}
|
||||
if (nextAssignee === undefined) {
|
||||
if (assignee === 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;
|
||||
if (nextAttachments === undefined) delete updated.attachments;
|
||||
if (nextBlockers === undefined) delete updated.blockers;
|
||||
if (!unassigned) delete updated.unassigned;
|
||||
if (milestoneId === undefined) delete updated.milestoneId;
|
||||
if (dueDate === undefined) delete updated.dueDate;
|
||||
if (attachment === undefined) delete updated.attachment;
|
||||
if (attachments === undefined) delete updated.attachments;
|
||||
if (blockers === undefined) delete updated.blockers;
|
||||
if (attachmentChanged) {
|
||||
delete updated.attachmentMarkdown;
|
||||
delete updated.attachmentMarkdowns;
|
||||
|
|
@ -340,6 +339,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
|||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.unassigned ? { unassigned: true } : {}),
|
||||
...(item.assignee ? { assignee: item.assignee } : {}),
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
|
|
|
|||
|
|
@ -1469,12 +1469,14 @@ async def create_issue(
|
|||
repository: str,
|
||||
title: str,
|
||||
body: str,
|
||||
assignee: str,
|
||||
assignee: str | None,
|
||||
label_ids: list[int] | None = None,
|
||||
milestone_id: int | None = None,
|
||||
due_date: str | None = None,
|
||||
) -> dict:
|
||||
payload: dict = {"title": title, "body": body, "assignee": assignee}
|
||||
payload: dict = {"title": title, "body": body}
|
||||
if assignee is not None:
|
||||
payload["assignee"] = assignee
|
||||
if label_ids:
|
||||
payload["labels"] = label_ids
|
||||
if milestone_id is not None:
|
||||
|
|
@ -1497,7 +1499,8 @@ async def create_issue(
|
|||
for item in assignees
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
]
|
||||
if confirmed_assignees != [assignee]:
|
||||
expected_assignees = [assignee] if assignee is not None else []
|
||||
if confirmed_assignees != expected_assignees:
|
||||
raise ValueError(
|
||||
"Gitea did not confirm self-assignment or exact issue assignment"
|
||||
)
|
||||
|
|
|
|||
12
src/main.py
12
src/main.py
|
|
@ -742,6 +742,7 @@ 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)
|
||||
unassigned: bool = False
|
||||
assignee: str | None = Field(
|
||||
default=None, pattern=r"^[A-Za-z0-9_.-]+$", max_length=255
|
||||
)
|
||||
|
|
@ -766,6 +767,12 @@ class IssueCreation(BaseModel):
|
|||
def strip_issue_body(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_one_owner_intent(self):
|
||||
if self.unassigned and self.assignee is not None:
|
||||
raise ValueError("assignee and unassigned cannot be requested together")
|
||||
return self
|
||||
|
||||
@field_validator("due_date")
|
||||
@classmethod
|
||||
def validate_due_date(cls, value: str | None) -> str | None:
|
||||
|
|
@ -4717,8 +4724,8 @@ 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:
|
||||
assignee = None if creation.unassigned else login
|
||||
if not creation.unassigned and creation.assignee and creation.assignee != login:
|
||||
eligible = {
|
||||
item["login"]
|
||||
for item in await gitea_proxy.issue_handoff_candidates(repository)
|
||||
|
|
@ -4772,6 +4779,7 @@ async def create_assigned_issue(
|
|||
creation.title,
|
||||
creation.body,
|
||||
creation.assignee,
|
||||
creation.unassigned,
|
||||
tuple(creation.label_ids),
|
||||
creation.milestone_id,
|
||||
creation.due_date,
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user