Merge pull request 'Plan new mobile issues at capture time' (#222) from timmy/221-plan-new-issues-at-capture into main
This commit is contained in:
commit
bca27f0330
|
|
@ -16,12 +16,15 @@ python3 -m pip install -r requirements.txt
|
||||||
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
||||||
token that can read dashboard data, update the authenticated user's notification
|
token that can read dashboard data, update the authenticated user's notification
|
||||||
threads, create and self-assign issues, discover, claim, and release issue assignments,
|
threads, create and self-assign issues, discover, claim, and release issue assignments,
|
||||||
set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
||||||
inspect/comment on assigned pull
|
inspect/comment on assigned pull
|
||||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||||
issue-comment API; mobile issue capture requires issue
|
issue-comment API; mobile issue capture requires issue
|
||||||
creation and assignment permission. Issue capture and authored mobile actions (issue
|
creation and assignment permission. The New issue sheet can optionally select an open
|
||||||
|
repository milestone and due date; the dashboard validates both and sends them with
|
||||||
|
self-assignment in the single create request, so planned work appears in its release
|
||||||
|
lane immediately. Issue capture and authored mobile actions (issue
|
||||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||||
another worker replays a confirmed result instead of posting duplicate content. Results
|
another worker replays a confirmed result instead of posting duplicate content. Results
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,25 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||||
)).slice(0, 20);
|
)).slice(0, 20);
|
||||||
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
|
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
|
||||||
|
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
|
||||||
|
const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
|
||||||
|
|
||||||
function loadStored() {
|
function loadStored() {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
||||||
if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''};
|
if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''};
|
||||||
return {
|
const draft = {
|
||||||
repository: String(parsed.repository || ''),
|
repository: String(parsed.repository || ''),
|
||||||
title: String(parsed.title || ''),
|
title: String(parsed.title || ''),
|
||||||
body: String(parsed.body || ''),
|
body: String(parsed.body || ''),
|
||||||
labelIds: safeLabelIds(parsed.labelIds),
|
labelIds: safeLabelIds(parsed.labelIds),
|
||||||
operationId: String(parsed.operationId || '').slice(0, 128),
|
operationId: String(parsed.operationId || '').slice(0, 128),
|
||||||
};
|
};
|
||||||
|
const milestoneId = safeMilestoneId(parsed.milestoneId);
|
||||||
|
const dueDate = safeDueDate(parsed.dueDate);
|
||||||
|
if (milestoneId !== null) draft.milestoneId = milestoneId;
|
||||||
|
if (dueDate) draft.dueDate = dueDate;
|
||||||
|
return draft;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return {...emptyDraft(), operationId: ''};
|
return {...emptyDraft(), operationId: ''};
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +52,12 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
body: String(draft?.body || ''),
|
body: String(draft?.body || ''),
|
||||||
labelIds: safeLabelIds(draft?.labelIds),
|
labelIds: safeLabelIds(draft?.labelIds),
|
||||||
};
|
};
|
||||||
const unchanged = ['repository', 'title', 'body'].every(key => previous[key] === safe[key]) &&
|
const milestoneId = safeMilestoneId(draft?.milestoneId);
|
||||||
|
const dueDate = safeDueDate(draft?.dueDate);
|
||||||
|
if (milestoneId !== null) safe.milestoneId = milestoneId;
|
||||||
|
if (dueDate) safe.dueDate = dueDate;
|
||||||
|
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
|
||||||
|
.every(key => (previous[key] || '') === (safe[key] || '')) &&
|
||||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
|
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
|
||||||
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
|
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
|
||||||
return safe;
|
return safe;
|
||||||
|
|
@ -73,6 +85,13 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadMilestones(repository) {
|
||||||
|
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||||
|
return fetchJson('api/v1/repos/' + encoded + '/milestones').then(milestones =>
|
||||||
|
Array.isArray(milestones) ? milestones : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function submit(draft) {
|
function submit(draft) {
|
||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
const saved = saveDraft(draft);
|
const saved = saveDraft(draft);
|
||||||
|
|
@ -88,6 +107,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: saved.title, body: saved.body, label_ids: saved.labelIds,
|
title: saved.title, body: saved.body, label_ids: saved.labelIds,
|
||||||
|
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
|
||||||
|
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
|
||||||
}),
|
}),
|
||||||
}).then(issue => {
|
}).then(issue => {
|
||||||
clearDraft();
|
clearDraft();
|
||||||
|
|
@ -96,7 +117,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { saveDraft, loadDraft, loadLabels, submit };
|
return { saveDraft, loadDraft, loadLabels, loadMilestones, submit };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||||||
.create-issue-form { display:grid; gap:12px; }
|
.create-issue-form { display:grid; gap:12px; }
|
||||||
.create-issue-form label { display:grid; gap:6px; }
|
.create-issue-form label { display:grid; gap:6px; }
|
||||||
.create-issue-form select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||||
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||||
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
|
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
|
||||||
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||||
|
|
@ -519,6 +519,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<label for="create-issue-body">Description <span class="small">Optional</span>
|
<label for="create-issue-body">Description <span class="small">Optional</span>
|
||||||
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
|
||||||
|
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
||||||
|
<option value="">No milestone</option>
|
||||||
|
</select>
|
||||||
|
<span class="small" id="create-issue-milestone-status" aria-live="polite">Choose a repository to load milestones.</span>
|
||||||
|
</label>
|
||||||
|
<label for="create-issue-due-date">Due date <span class="small">Optional</span>
|
||||||
|
<input id="create-issue-due-date" type="date" />
|
||||||
|
</label>
|
||||||
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
|
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
|
||||||
<legend>Labels <span class="small">Optional</span></legend>
|
<legend>Labels <span class="small">Optional</span></legend>
|
||||||
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
|
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
|
||||||
|
|
@ -1757,6 +1766,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
title: qs('#create-issue-title').value,
|
title: qs('#create-issue-title').value,
|
||||||
body: qs('#create-issue-body').value,
|
body: qs('#create-issue-body').value,
|
||||||
labelIds: selectedIssueLabelIds(),
|
labelIds: selectedIssueLabelIds(),
|
||||||
|
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
||||||
|
dueDate: qs('#create-issue-due-date').value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1783,6 +1794,30 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadIssueMilestones(repository, selectedId = null) {
|
||||||
|
const select = qs('#create-issue-milestone');
|
||||||
|
const status = qs('#create-issue-milestone-status');
|
||||||
|
select.innerHTML = '<option value="">No milestone</option>';
|
||||||
|
if (!repository) {
|
||||||
|
status.textContent = 'Choose a repository to load milestones.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = 'Loading milestones…';
|
||||||
|
try {
|
||||||
|
const milestones = await issueCapture.loadMilestones(repository);
|
||||||
|
if (qs('#create-issue-repository').value !== repository) return;
|
||||||
|
select.innerHTML += milestones.map(milestone =>
|
||||||
|
'<option value="' + Number(milestone.id) + '">' + escapeHtml(milestone.title) + '</option>'
|
||||||
|
).join('');
|
||||||
|
if (selectedId) select.value = String(selectedId);
|
||||||
|
status.textContent = milestones.length ?
|
||||||
|
'Choose the release lane for this issue.' : 'This repository has no open milestones.';
|
||||||
|
} catch (_error) {
|
||||||
|
if (qs('#create-issue-repository').value !== repository) return;
|
||||||
|
status.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateIssueSheet() {
|
function openCreateIssueSheet() {
|
||||||
const captureDraft = issueCapture.loadDraft();
|
const captureDraft = issueCapture.loadDraft();
|
||||||
const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
||||||
|
|
@ -1795,7 +1830,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
|
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
|
||||||
qs('#create-issue-title').value = captureDraft.title;
|
qs('#create-issue-title').value = captureDraft.title;
|
||||||
qs('#create-issue-body').value = captureDraft.body;
|
qs('#create-issue-body').value = captureDraft.body;
|
||||||
|
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
||||||
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
||||||
|
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||||||
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
|
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
|
||||||
qs('#submit-new-issue').disabled = !repositories.length;
|
qs('#submit-new-issue').disabled = !repositories.length;
|
||||||
qs('#create-issue-sheet').classList.add('open');
|
qs('#create-issue-sheet').classList.add('open');
|
||||||
|
|
@ -2367,14 +2404,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
saveIssueCaptureDraft();
|
saveIssueCaptureDraft();
|
||||||
closeCreateIssueSheet();
|
closeCreateIssueSheet();
|
||||||
});
|
});
|
||||||
['#create-issue-title', '#create-issue-body'].forEach(selector =>
|
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
|
||||||
qs(selector).addEventListener('input', saveIssueCaptureDraft)
|
qs(selector).addEventListener('input', saveIssueCaptureDraft)
|
||||||
);
|
);
|
||||||
qs('#create-issue-repository').addEventListener('change', event => {
|
qs('#create-issue-repository').addEventListener('change', event => {
|
||||||
loadIssueLabels(event.target.value);
|
loadIssueLabels(event.target.value);
|
||||||
|
loadIssueMilestones(event.target.value);
|
||||||
saveIssueCaptureDraft();
|
saveIssueCaptureDraft();
|
||||||
});
|
});
|
||||||
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
|
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
|
||||||
|
qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft);
|
||||||
qs('#create-issue-form').addEventListener('submit', async event => {
|
qs('#create-issue-form').addEventListener('submit', async event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const captureDraft = {
|
const captureDraft = {
|
||||||
|
|
@ -2382,6 +2421,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
title: qs('#create-issue-title').value.trim(),
|
title: qs('#create-issue-title').value.trim(),
|
||||||
body: qs('#create-issue-body').value.trim(),
|
body: qs('#create-issue-body').value.trim(),
|
||||||
labelIds: selectedIssueLabelIds(),
|
labelIds: selectedIssueLabelIds(),
|
||||||
|
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
||||||
|
dueDate: qs('#create-issue-due-date').value,
|
||||||
};
|
};
|
||||||
if (!captureDraft.repository || !captureDraft.title) {
|
if (!captureDraft.repository || !captureDraft.title) {
|
||||||
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
||||||
|
|
|
||||||
|
|
@ -751,10 +751,16 @@ async def create_issue(
|
||||||
body: str,
|
body: str,
|
||||||
assignee: str,
|
assignee: str,
|
||||||
label_ids: list[int] | None = None,
|
label_ids: list[int] | None = None,
|
||||||
|
milestone_id: int | None = None,
|
||||||
|
due_date: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
payload: dict = {"title": title, "body": body, "assignee": assignee}
|
payload: dict = {"title": title, "body": body, "assignee": assignee}
|
||||||
if label_ids:
|
if label_ids:
|
||||||
payload["labels"] = label_ids
|
payload["labels"] = label_ids
|
||||||
|
if milestone_id is not None:
|
||||||
|
payload["milestone"] = milestone_id
|
||||||
|
if due_date is not None:
|
||||||
|
payload["due_date"] = due_date
|
||||||
response = await _get_client().post(
|
response = await _get_client().post(
|
||||||
f"/api/v1/repos/{repository}/issues",
|
f"/api/v1/repos/{repository}/issues",
|
||||||
headers=_auth(),
|
headers=_auth(),
|
||||||
|
|
@ -775,6 +781,22 @@ async def create_issue(
|
||||||
raise ValueError("Gitea did not confirm issue self-assignment")
|
raise ValueError("Gitea did not confirm issue self-assignment")
|
||||||
labels_value = issue.get("labels")
|
labels_value = issue.get("labels")
|
||||||
labels = labels_value if isinstance(labels_value, list) else []
|
labels = labels_value if isinstance(labels_value, list) else []
|
||||||
|
milestone_value = issue.get("milestone")
|
||||||
|
milestone = (
|
||||||
|
{"id": milestone_value["id"], "title": milestone_value["title"]}
|
||||||
|
if isinstance(milestone_value, dict)
|
||||||
|
and isinstance(milestone_value.get("id"), int)
|
||||||
|
and isinstance(milestone_value.get("title"), str)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
confirmed_due_date = (
|
||||||
|
issue.get("due_date") if isinstance(issue.get("due_date"), str) else None
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
(milestone_id is not None and (milestone or {}).get("id") != milestone_id)
|
||||||
|
or (due_date is not None and confirmed_due_date != due_date)
|
||||||
|
):
|
||||||
|
raise ValueError("Gitea did not confirm issue release plan")
|
||||||
return {
|
return {
|
||||||
"id": issue.get("id"),
|
"id": issue.get("id"),
|
||||||
"number": issue["number"],
|
"number": issue["number"],
|
||||||
|
|
@ -791,6 +813,8 @@ async def create_issue(
|
||||||
if isinstance(item, dict) and isinstance(item.get("name"), str)
|
if isinstance(item, dict) and isinstance(item.get("name"), str)
|
||||||
],
|
],
|
||||||
"assignees": confirmed_assignees,
|
"assignees": confirmed_assignees,
|
||||||
|
"milestone": milestone,
|
||||||
|
"due_date": confirmed_due_date,
|
||||||
"updated_at": issue.get("updated_at", "")
|
"updated_at": issue.get("updated_at", "")
|
||||||
if isinstance(issue.get("updated_at"), str)
|
if isinstance(issue.get("updated_at"), str)
|
||||||
else "",
|
else "",
|
||||||
|
|
|
||||||
37
src/main.py
37
src/main.py
|
|
@ -4,6 +4,7 @@ import os
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Coroutine
|
from collections.abc import Awaitable, Coroutine
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
|
@ -151,6 +152,12 @@ class IssueCreation(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=255)
|
title: str = Field(min_length=1, max_length=255)
|
||||||
body: str = Field(default="", max_length=10_000)
|
body: str = Field(default="", max_length=10_000)
|
||||||
label_ids: list[int] = Field(default_factory=list, max_length=20)
|
label_ids: list[int] = Field(default_factory=list, max_length=20)
|
||||||
|
milestone_id: PositiveInt | None = None
|
||||||
|
due_date: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
pattern=r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$",
|
||||||
|
max_length=20,
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("title")
|
@field_validator("title")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -165,6 +172,13 @@ class IssueCreation(BaseModel):
|
||||||
def strip_issue_body(cls, value: str) -> str:
|
def strip_issue_body(cls, value: str) -> str:
|
||||||
return value.strip()
|
return value.strip()
|
||||||
|
|
||||||
|
@field_validator("due_date")
|
||||||
|
@classmethod
|
||||||
|
def validate_due_date(cls, value: str | None) -> str | None:
|
||||||
|
if value is not None:
|
||||||
|
datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class IssueContentUpdate(BaseModel):
|
class IssueContentUpdate(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=255)
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
|
@ -1439,6 +1453,27 @@ async def create_assigned_issue(
|
||||||
}
|
}
|
||||||
if any(label_id not in valid_label_ids for label_id in creation.label_ids):
|
if any(label_id not in valid_label_ids for label_id in creation.label_ids):
|
||||||
raise HTTPException(status_code=422, detail="Unknown repository label")
|
raise HTTPException(status_code=422, detail="Unknown repository label")
|
||||||
|
if creation.milestone_id is not None:
|
||||||
|
available_milestones = await gitea_proxy.repo_milestones(repository)
|
||||||
|
valid_milestone_ids = {
|
||||||
|
item.get("id")
|
||||||
|
for item in available_milestones
|
||||||
|
if isinstance(item, dict) and isinstance(item.get("id"), int)
|
||||||
|
}
|
||||||
|
if creation.milestone_id not in valid_milestone_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail="Unknown open repository milestone"
|
||||||
|
)
|
||||||
|
if creation.milestone_id is not None or creation.due_date is not None:
|
||||||
|
return await gitea_proxy.create_issue(
|
||||||
|
repository,
|
||||||
|
creation.title,
|
||||||
|
creation.body,
|
||||||
|
login,
|
||||||
|
creation.label_ids,
|
||||||
|
creation.milestone_id,
|
||||||
|
creation.due_date,
|
||||||
|
)
|
||||||
return await gitea_proxy.create_issue(
|
return await gitea_proxy.create_issue(
|
||||||
repository, creation.title, creation.body, login, creation.label_ids
|
repository, creation.title, creation.body, login, creation.label_ids
|
||||||
)
|
)
|
||||||
|
|
@ -1453,6 +1488,8 @@ async def create_assigned_issue(
|
||||||
creation.title,
|
creation.title,
|
||||||
creation.body,
|
creation.body,
|
||||||
tuple(creation.label_ids),
|
tuple(creation.label_ids),
|
||||||
|
creation.milestone_id,
|
||||||
|
creation.due_date,
|
||||||
),
|
),
|
||||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,55 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||||
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
|
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
async def available_repos():
|
||||||
|
return [{"full_name": "stackchain/api"}]
|
||||||
|
|
||||||
|
async def milestones(repository):
|
||||||
|
assert repository == "stackchain/api"
|
||||||
|
return [{"id": 9, "title": "August RC"}]
|
||||||
|
|
||||||
|
async def create(repository, title, body, assignee, label_ids, milestone_id, due_date):
|
||||||
|
calls.append((repository, title, body, assignee, label_ids, milestone_id, due_date))
|
||||||
|
return {
|
||||||
|
"id": 221, "number": 221, "title": title, "state": "open",
|
||||||
|
"repository": repository, "labels": [], "assignees": [assignee],
|
||||||
|
"milestone": {"id": 9, "title": "August RC"},
|
||||||
|
"due_date": "2026-08-31T23:59:59Z",
|
||||||
|
"updated_at": "2026-08-07T20:00:00Z",
|
||||||
|
"url": "https://forge.example/stackchain/api/issues/221",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones)
|
||||||
|
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": "Ship mobile plan",
|
||||||
|
"milestone_id": 9,
|
||||||
|
"due_date": "2026-08-31T23:59:59Z",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.json()["milestone"] == {"id": 9, "title": "August RC"}
|
||||||
|
assert response.json()["due_date"] == "2026-08-31T23:59:59Z"
|
||||||
|
assert calls == [(
|
||||||
|
"stackchain/api", "Ship mobile plan", "", "timmy", [], 9,
|
||||||
|
"2026-08-31T23:59:59Z",
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_create_issue_replays_one_upstream_result_for_concurrent_idempotent_requests(monkeypatch):
|
async def test_create_issue_replays_one_upstream_result_for_concurrent_idempotent_requests(monkeypatch):
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|
@ -630,6 +679,26 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
|
||||||
assert result["labels"] == ["P0"]
|
assert result["labels"] == ["P0"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_gitea_create_issue_requires_confirmed_release_plan():
|
||||||
|
async def handler(_request):
|
||||||
|
return httpx.Response(201, json={
|
||||||
|
"id": 221, "number": 221, "title": "Planned work", "state": "open",
|
||||||
|
"assignees": [{"login": "timmy"}], "labels": [],
|
||||||
|
"milestone": None, "due_date": "2026-09-01T23:59:59Z",
|
||||||
|
})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError, match="release plan"):
|
||||||
|
await gitea_proxy.create_issue(
|
||||||
|
"stackchain/api", "Planned work", "", "timmy", [], 9,
|
||||||
|
"2026-08-31T23:59:59Z",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_gitea_repo_labels_returns_safe_touch_picker_options():
|
async def test_gitea_repo_labels_returns_safe_touch_picker_options():
|
||||||
requests = []
|
requests = []
|
||||||
|
|
@ -702,6 +771,27 @@ async def test_create_issue_rejects_blank_title_before_upstream(monkeypatch):
|
||||||
assert called is False
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_issue_rejects_nonexistent_due_date_before_upstream(monkeypatch):
|
||||||
|
called = False
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||||
|
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": "Impossible plan", "due_date": "2026-02-31T23:59:59Z"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
|
async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
|
||||||
created = False
|
created = False
|
||||||
|
|
|
||||||
|
|
@ -521,6 +521,19 @@ async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_b
|
||||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
|
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="create-issue-milestone"' in html
|
||||||
|
assert 'id="create-issue-due-date" type="date"' in html
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor():
|
async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
@ -1245,6 +1258,50 @@ capture.loadLabels('stackchain/api').then(labels => process.stdout.write(JSON.st
|
||||||
assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"]
|
assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_capture_persists_and_submits_milestone_and_due_date():
|
||||||
|
script = f"""
|
||||||
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||||
|
const values = new Map();
|
||||||
|
const calls = [];
|
||||||
|
const storage = {{
|
||||||
|
getItem:key => values.get(key) || null,
|
||||||
|
setItem:(key,value) => values.set(key,value),
|
||||||
|
removeItem:key => values.delete(key),
|
||||||
|
}};
|
||||||
|
const capture = createIssueCapture({{
|
||||||
|
storage,
|
||||||
|
createOperationId: () => 'planned-operation',
|
||||||
|
fetchJson: (url, options) => {{
|
||||||
|
calls.push({{url, body:options?.body ? JSON.parse(options.body) : null}});
|
||||||
|
if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}}]);
|
||||||
|
return Promise.resolve({{number:221, milestone:{{id:9,title:'August RC'}}, due_date:'2026-08-31T23:59:59Z'}});
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
const draft = {{repository:'stackchain/api', title:'Ship plan', body:'', labelIds:[], milestoneId:9, dueDate:'2026-08-31'}};
|
||||||
|
capture.saveDraft(draft);
|
||||||
|
Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.loadDraft())]).then(results =>
|
||||||
|
process.stdout.write(JSON.stringify({{calls, stored:results[1], milestones:results[0]}}))
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
output = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert output["calls"] == [
|
||||||
|
{"url": "api/v1/repos/stackchain/api/milestones", "body": None},
|
||||||
|
{
|
||||||
|
"url": "api/v1/repos/stackchain/api/issues",
|
||||||
|
"body": {
|
||||||
|
"title": "Ship plan", "body": "", "label_ids": [],
|
||||||
|
"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
assert output["milestones"] == [{"id": 9, "title": "August RC"}]
|
||||||
|
assert output["stored"]["number"] == 221
|
||||||
|
|
||||||
|
|
||||||
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
|
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
|
||||||
payload = {
|
payload = {
|
||||||
"user": {"login": "timmy"},
|
"user": {"login": "timmy"},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user