Merge pull request 'Select priority labels while creating mobile issues' (#164) from timmy/163-mobile-issue-labels into main
This commit is contained in:
commit
4b7405aa65
|
|
@ -1,13 +1,17 @@
|
||||||
function createIssueCapture({ fetchJson, storage }) {
|
function createIssueCapture({ fetchJson, storage }) {
|
||||||
const storageKey = 'stackchain.issue-capture.v1';
|
const storageKey = 'stackchain.issue-capture.v1';
|
||||||
let pending = null;
|
let pending = null;
|
||||||
const emptyDraft = () => ({ repository: '', title: '', body: '' });
|
const safeLabelIds = value => Array.from(new Set(
|
||||||
|
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||||
|
)).slice(0, 20);
|
||||||
|
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
|
||||||
|
|
||||||
function saveDraft(draft) {
|
function saveDraft(draft) {
|
||||||
const safe = {
|
const safe = {
|
||||||
repository: String(draft?.repository || ''),
|
repository: String(draft?.repository || ''),
|
||||||
title: String(draft?.title || ''),
|
title: String(draft?.title || ''),
|
||||||
body: String(draft?.body || ''),
|
body: String(draft?.body || ''),
|
||||||
|
labelIds: safeLabelIds(draft?.labelIds),
|
||||||
};
|
};
|
||||||
try { storage.setItem(storageKey, JSON.stringify(safe)); }
|
try { storage.setItem(storageKey, JSON.stringify(safe)); }
|
||||||
catch (_error) { /* Keep the form as the in-memory fallback. */ }
|
catch (_error) { /* Keep the form as the in-memory fallback. */ }
|
||||||
|
|
@ -21,6 +25,7 @@ function createIssueCapture({ fetchJson, storage }) {
|
||||||
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),
|
||||||
} : emptyDraft();
|
} : emptyDraft();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return emptyDraft();
|
return emptyDraft();
|
||||||
|
|
@ -32,6 +37,18 @@ function createIssueCapture({ fetchJson, storage }) {
|
||||||
catch (_error) { /* Confirmed creation remains authoritative. */ }
|
catch (_error) { /* Confirmed creation remains authoritative. */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadLabels(repository) {
|
||||||
|
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||||
|
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||||
|
return fetchJson('api/v1/repos/' + encoded + '/labels').then(labels =>
|
||||||
|
(Array.isArray(labels) ? labels : []).slice().sort((left, right) => {
|
||||||
|
const leftPriority = priorities.has(String(left?.name || '').toLowerCase());
|
||||||
|
const rightPriority = priorities.has(String(right?.name || '').toLowerCase());
|
||||||
|
return Number(rightPriority) - Number(leftPriority);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function submit(draft) {
|
function submit(draft) {
|
||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
const saved = saveDraft(draft);
|
const saved = saveDraft(draft);
|
||||||
|
|
@ -39,7 +56,9 @@ function createIssueCapture({ fetchJson, storage }) {
|
||||||
pending = fetchJson('api/v1/repos/' + repository + '/issues', {
|
pending = fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title: saved.title, body: saved.body }),
|
body: JSON.stringify({
|
||||||
|
title: saved.title, body: saved.body, label_ids: saved.labelIds,
|
||||||
|
}),
|
||||||
}).then(issue => {
|
}).then(issue => {
|
||||||
clearDraft();
|
clearDraft();
|
||||||
return issue;
|
return issue;
|
||||||
|
|
@ -47,7 +66,7 @@ function createIssueCapture({ fetchJson, storage }) {
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { saveDraft, loadDraft, submit };
|
return { saveDraft, loadDraft, loadLabels, submit };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.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 { 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-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 input { width:20px; height:20px; margin:0; }
|
||||||
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||||
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||||
.pull-sheet.open { display:flex; }
|
.pull-sheet.open { display:flex; }
|
||||||
|
|
@ -332,6 +336,11 @@ 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>
|
||||||
|
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
|
||||||
|
<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="create-issue-label-list" id="create-issue-label-list"></div>
|
||||||
|
</fieldset>
|
||||||
<div class="small">The issue will be assigned to you.</div>
|
<div class="small">The issue will be assigned to you.</div>
|
||||||
<div class="create-issue-actions">
|
<div class="create-issue-actions">
|
||||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||||
|
|
@ -948,14 +957,43 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
if (pullTrigger?.isConnected) pullTrigger.focus();
|
if (pullTrigger?.isConnected) pullTrigger.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectedIssueLabelIds() {
|
||||||
|
return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked'))
|
||||||
|
.map(input => Number(input.value)).filter(Number.isInteger);
|
||||||
|
}
|
||||||
|
|
||||||
function saveIssueCaptureDraft() {
|
function saveIssueCaptureDraft() {
|
||||||
issueCapture.saveDraft({
|
issueCapture.saveDraft({
|
||||||
repository: qs('#create-issue-repository').value,
|
repository: qs('#create-issue-repository').value,
|
||||||
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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadIssueLabels(repository, selectedIds = []) {
|
||||||
|
const list = qs('#create-issue-label-list');
|
||||||
|
const status = qs('#create-issue-label-status');
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!repository) {
|
||||||
|
status.textContent = 'Choose a repository to load labels.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = 'Loading labels…';
|
||||||
|
try {
|
||||||
|
const labels = await issueCapture.loadLabels(repository);
|
||||||
|
const selected = new Set(selectedIds.map(Number));
|
||||||
|
list.innerHTML = labels.map(label =>
|
||||||
|
'<label class="create-issue-label-option"><input type="checkbox" name="create-issue-label" value="' +
|
||||||
|
Number(label.id) + '"' + (selected.has(Number(label.id)) ? ' checked' : '') + '><span>' +
|
||||||
|
escapeHtml(label.name) + '</span></label>'
|
||||||
|
).join('');
|
||||||
|
status.textContent = labels.length ? 'Select labels to triage this issue.' : 'This repository has no labels.';
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateIssueSheet() {
|
function openCreateIssueSheet() {
|
||||||
const captureDraft = issueCapture.loadDraft();
|
const captureDraft = issueCapture.loadDraft();
|
||||||
const repositories = lastContextSnapshot?.repos || [];
|
const repositories = lastContextSnapshot?.repos || [];
|
||||||
|
|
@ -967,6 +1005,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
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;
|
||||||
|
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
||||||
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');
|
||||||
|
|
@ -1256,15 +1295,21 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
saveIssueCaptureDraft();
|
saveIssueCaptureDraft();
|
||||||
closeCreateIssueSheet();
|
closeCreateIssueSheet();
|
||||||
});
|
});
|
||||||
['#create-issue-repository', '#create-issue-title', '#create-issue-body'].forEach(selector =>
|
['#create-issue-title', '#create-issue-body'].forEach(selector =>
|
||||||
qs(selector).addEventListener('input', saveIssueCaptureDraft)
|
qs(selector).addEventListener('input', saveIssueCaptureDraft)
|
||||||
);
|
);
|
||||||
|
qs('#create-issue-repository').addEventListener('change', event => {
|
||||||
|
loadIssueLabels(event.target.value);
|
||||||
|
saveIssueCaptureDraft();
|
||||||
|
});
|
||||||
|
qs('#create-issue-label-list').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 = {
|
||||||
repository: qs('#create-issue-repository').value,
|
repository: qs('#create-issue-repository').value,
|
||||||
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(),
|
||||||
};
|
};
|
||||||
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.';
|
||||||
|
|
|
||||||
|
|
@ -372,11 +372,46 @@ async def comment_on_issue(repository: str, number: int, body: str) -> dict:
|
||||||
return _normalize_issue_comment(comment)
|
return _normalize_issue_comment(comment)
|
||||||
|
|
||||||
|
|
||||||
async def create_issue(repository: str, title: str, body: str, assignee: str) -> dict:
|
async def repo_labels(repository: str) -> list[dict]:
|
||||||
|
response = await _get_client().get(
|
||||||
|
f"/api/v1/repos/{repository}/labels",
|
||||||
|
headers=_auth(),
|
||||||
|
params={"limit": 50, "page": 1},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise ValueError("Gitea labels response was not a list")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": item["id"],
|
||||||
|
"name": item["name"],
|
||||||
|
"color": item.get("color", "") if isinstance(item.get("color"), str) else "",
|
||||||
|
"description": item.get("description", "")
|
||||||
|
if isinstance(item.get("description"), str)
|
||||||
|
else "",
|
||||||
|
}
|
||||||
|
for item in payload
|
||||||
|
if isinstance(item, dict)
|
||||||
|
and isinstance(item.get("id"), int)
|
||||||
|
and isinstance(item.get("name"), str)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def create_issue(
|
||||||
|
repository: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
assignee: str,
|
||||||
|
label_ids: list[int] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
payload: dict = {"title": title, "body": body, "assignee": assignee}
|
||||||
|
if label_ids:
|
||||||
|
payload["labels"] = label_ids
|
||||||
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(),
|
||||||
json={"title": title, "body": body, "assignee": assignee},
|
json=payload,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
issue = response.json()
|
issue = response.json()
|
||||||
|
|
@ -391,6 +426,8 @@ async def create_issue(repository: str, title: str, body: str, assignee: str) ->
|
||||||
]
|
]
|
||||||
if assignee not in confirmed_assignees:
|
if assignee not in confirmed_assignees:
|
||||||
raise ValueError("Gitea did not confirm issue self-assignment")
|
raise ValueError("Gitea did not confirm issue self-assignment")
|
||||||
|
labels_value = issue.get("labels")
|
||||||
|
labels = labels_value if isinstance(labels_value, list) else []
|
||||||
return {
|
return {
|
||||||
"id": issue.get("id"),
|
"id": issue.get("id"),
|
||||||
"number": issue["number"],
|
"number": issue["number"],
|
||||||
|
|
@ -401,7 +438,11 @@ async def create_issue(repository: str, title: str, body: str, assignee: str) ->
|
||||||
if isinstance(issue.get("state"), str)
|
if isinstance(issue.get("state"), str)
|
||||||
else "",
|
else "",
|
||||||
"repository": repository,
|
"repository": repository,
|
||||||
"labels": [],
|
"labels": [
|
||||||
|
item["name"]
|
||||||
|
for item in labels
|
||||||
|
if isinstance(item, dict) and isinstance(item.get("name"), str)
|
||||||
|
],
|
||||||
"assignees": confirmed_assignees,
|
"assignees": confirmed_assignees,
|
||||||
"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)
|
||||||
|
|
|
||||||
42
src/main.py
42
src/main.py
|
|
@ -109,6 +109,7 @@ class IssueComment(BaseModel):
|
||||||
class IssueCreation(BaseModel):
|
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)
|
||||||
|
|
||||||
@field_validator("title")
|
@field_validator("title")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -202,6 +203,9 @@ async def prevent_live_api_caching(request, call_next):
|
||||||
) or (
|
) or (
|
||||||
request.url.path.startswith("/api/v1/repos/")
|
request.url.path.startswith("/api/v1/repos/")
|
||||||
and request.url.path.endswith("/issues")
|
and request.url.path.endswith("/issues")
|
||||||
|
) or (
|
||||||
|
request.url.path.startswith("/api/v1/repos/")
|
||||||
|
and request.url.path.endswith("/labels")
|
||||||
):
|
):
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
return response
|
return response
|
||||||
|
|
@ -734,6 +738,33 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/repos/{owner}/{repo}/labels")
|
||||||
|
async def repository_labels(owner: str, repo: str):
|
||||||
|
repository = f"{owner}/{repo}"
|
||||||
|
|
||||||
|
async def load_labels():
|
||||||
|
available = await gitea_proxy.repos()
|
||||||
|
accessible = {
|
||||||
|
item.get("full_name")
|
||||||
|
for item in (available if isinstance(available, list) else [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
|
if repository not in accessible:
|
||||||
|
raise HTTPException(status_code=404, detail="Repository not found")
|
||||||
|
return await gitea_proxy.repo_labels(repository)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(load_labels(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Labels could not be loaded. Issue creation is still available."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201)
|
@app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201)
|
||||||
async def create_assigned_issue(creation: IssueCreation, owner: str, repo: str):
|
async def create_assigned_issue(creation: IssueCreation, owner: str, repo: str):
|
||||||
repository = f"{owner}/{repo}"
|
repository = f"{owner}/{repo}"
|
||||||
|
|
@ -750,8 +781,17 @@ async def create_assigned_issue(creation: IssueCreation, owner: str, repo: str):
|
||||||
}
|
}
|
||||||
if not login or repository not in accessible:
|
if not login or repository not in accessible:
|
||||||
raise HTTPException(status_code=404, detail="Repository not found")
|
raise HTTPException(status_code=404, detail="Repository not found")
|
||||||
|
if creation.label_ids:
|
||||||
|
available_labels = await gitea_proxy.repo_labels(repository)
|
||||||
|
valid_label_ids = {
|
||||||
|
item.get("id")
|
||||||
|
for item in available_labels
|
||||||
|
if isinstance(item, dict) and isinstance(item.get("id"), int)
|
||||||
|
}
|
||||||
|
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")
|
||||||
return await gitea_proxy.create_issue(
|
return await gitea_proxy.create_issue(
|
||||||
repository, creation.title, creation.body, login
|
repository, creation.title, creation.body, login, creation.label_ids
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,22 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||||
async def available_repos():
|
async def available_repos():
|
||||||
return [{"full_name": "stackchain/api"}]
|
return [{"full_name": "stackchain/api"}]
|
||||||
|
|
||||||
async def create(repository, title, body, assignee):
|
async def labels(repository):
|
||||||
calls.append((repository, title, body, assignee))
|
assert repository == "stackchain/api"
|
||||||
|
return [
|
||||||
|
{"id": 3, "name": "P0", "color": "d73a4a"},
|
||||||
|
{"id": 8, "name": "frontend", "color": "1d76db"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create(repository, title, body, assignee, label_ids):
|
||||||
|
calls.append((repository, title, body, assignee, label_ids))
|
||||||
return {
|
return {
|
||||||
"id": 81,
|
"id": 81,
|
||||||
"number": 17,
|
"number": 17,
|
||||||
"title": title,
|
"title": title,
|
||||||
"state": "open",
|
"state": "open",
|
||||||
"repository": repository,
|
"repository": repository,
|
||||||
"labels": [],
|
"labels": ["P0"],
|
||||||
"assignees": [assignee],
|
"assignees": [assignee],
|
||||||
"updated_at": "2026-08-07T03:00:00Z",
|
"updated_at": "2026-08-07T03:00:00Z",
|
||||||
"url": "https://forge.example/stackchain/api/issues/17",
|
"url": "https://forge.example/stackchain/api/issues/17",
|
||||||
|
|
@ -32,19 +39,25 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||||
|
|
||||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False)
|
||||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create, raising=False)
|
monkeypatch.setattr(main.gitea_proxy, "create_issue", create, raising=False)
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/v1/repos/stackchain/api/issues",
|
"/api/v1/repos/stackchain/api/issues",
|
||||||
json={"title": " Capture mobile work ", "body": " Context "},
|
json={
|
||||||
|
"title": " Capture mobile work ",
|
||||||
|
"body": " Context ",
|
||||||
|
"label_ids": [3],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
assert response.headers["cache-control"] == "no-store"
|
assert response.headers["cache-control"] == "no-store"
|
||||||
assert response.json()["number"] == 17
|
assert response.json()["number"] == 17
|
||||||
assert response.json()["assignees"] == ["timmy"]
|
assert response.json()["assignees"] == ["timmy"]
|
||||||
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy")]
|
assert response.json()["labels"] == ["P0"]
|
||||||
|
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -58,12 +71,13 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
|
||||||
"updated_at": "2026-08-07T03:00:00Z",
|
"updated_at": "2026-08-07T03:00:00Z",
|
||||||
"html_url": "https://forge.example/stackchain/api/issues/17",
|
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||||
"assignees": [{"login": "timmy"}],
|
"assignees": [{"login": "timmy"}],
|
||||||
|
"labels": [{"id": 3, "name": "P0"}],
|
||||||
})
|
})
|
||||||
|
|
||||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
try:
|
try:
|
||||||
result = await gitea_proxy.create_issue(
|
result = await gitea_proxy.create_issue(
|
||||||
"stackchain/api", "Capture mobile work", "Context", "timmy"
|
"stackchain/api", "Capture mobile work", "Context", "timmy", [3]
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await gitea_proxy.stop_client()
|
await gitea_proxy.stop_client()
|
||||||
|
|
@ -72,11 +86,63 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
|
||||||
assert requests[0].method == "POST"
|
assert requests[0].method == "POST"
|
||||||
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues"
|
assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues"
|
||||||
assert requests[0].content == (
|
assert requests[0].content == (
|
||||||
b'{"title":"Capture mobile work","body":"Context","assignee":"timmy"}'
|
b'{"title":"Capture mobile work","body":"Context","assignee":"timmy","labels":[3]}'
|
||||||
)
|
)
|
||||||
assert result["repository"] == "stackchain/api"
|
assert result["repository"] == "stackchain/api"
|
||||||
assert result["number"] == 17
|
assert result["number"] == 17
|
||||||
assert result["assignees"] == ["timmy"]
|
assert result["assignees"] == ["timmy"]
|
||||||
|
assert result["labels"] == ["P0"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_gitea_repo_labels_returns_safe_touch_picker_options():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
requests.append(request)
|
||||||
|
return httpx.Response(200, json=[
|
||||||
|
{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"},
|
||||||
|
{"id": "bad", "name": "invalid", "color": "000000"},
|
||||||
|
])
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.repo_labels("stackchain/api")
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert requests[0].url.path == "/api/v1/repos/stackchain/api/labels"
|
||||||
|
assert requests[0].url.params["limit"] == "50"
|
||||||
|
assert result == [{
|
||||||
|
"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def available_repos():
|
||||||
|
return [{"full_name": "stackchain/api"}]
|
||||||
|
|
||||||
|
async def labels(repository):
|
||||||
|
calls.append(repository)
|
||||||
|
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/api/v1/repos/stackchain/api/labels")
|
||||||
|
missing = await client.get("/api/v1/repos/other/private/labels")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert response.json() == [{
|
||||||
|
"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"
|
||||||
|
}]
|
||||||
|
assert missing.status_code == 404
|
||||||
|
assert calls == ["stackchain/api"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -100,6 +166,39 @@ 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_label_not_in_target_repository(monkeypatch):
|
||||||
|
created = False
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
async def available_repos():
|
||||||
|
return [{"full_name": "stackchain/api"}]
|
||||||
|
|
||||||
|
async def labels(_repository):
|
||||||
|
return [{"id": 3, "name": "P0"}]
|
||||||
|
|
||||||
|
async def create(*_args):
|
||||||
|
nonlocal created
|
||||||
|
created = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||||
|
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": "Urgent work", "label_ids": [999]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["detail"] == "Unknown repository label"
|
||||||
|
assert created is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_gitea_create_issue_requires_confirmed_self_assignment():
|
async def test_gitea_create_issue_requires_confirmed_self_assignment():
|
||||||
async def handler(_request):
|
async def handler(_request):
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ const capture = createIssueCapture({{
|
||||||
}}); }});
|
}}); }});
|
||||||
}},
|
}},
|
||||||
}});
|
}});
|
||||||
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context'}};
|
const draft = {{repository:'stackchain/api', title:'Capture work', body:'Context', labelIds:[3]}};
|
||||||
capture.saveDraft(draft);
|
capture.saveDraft(draft);
|
||||||
const first = capture.submit(draft);
|
const first = capture.submit(draft);
|
||||||
const duplicate = capture.submit(draft);
|
const duplicate = capture.submit(draft);
|
||||||
|
|
@ -159,16 +159,45 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||||
assert output["calls"] == [{
|
assert output["calls"] == [{
|
||||||
"url": "api/v1/repos/stackchain/api/issues",
|
"url": "api/v1/repos/stackchain/api/issues",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"body": {"title": "Capture work", "body": "Context"},
|
"body": {"title": "Capture work", "body": "Context", "label_ids": [3]},
|
||||||
}]
|
}]
|
||||||
assert output["during"] == {
|
assert output["during"] == {
|
||||||
"repository": "stackchain/api", "title": "Capture work", "body": "Context"
|
"repository": "stackchain/api", "title": "Capture work", "body": "Context",
|
||||||
|
"labelIds": [3],
|
||||||
|
}
|
||||||
|
assert output["after"] == {
|
||||||
|
"repository": "", "title": "", "body": "", "labelIds": []
|
||||||
}
|
}
|
||||||
assert output["after"] == {"repository": "", "title": "", "body": ""}
|
|
||||||
assert output["results"][0]["number"] == 17
|
assert output["results"][0]["number"] == 17
|
||||||
assert output["results"][1]["number"] == 17
|
assert output["results"][1]["number"] == 17
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_capture_loads_repository_labels_with_priorities_first():
|
||||||
|
script = f"""
|
||||||
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||||
|
const calls = [];
|
||||||
|
const capture = createIssueCapture({{
|
||||||
|
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
|
||||||
|
fetchJson: url => {{
|
||||||
|
calls.push(url);
|
||||||
|
return Promise.resolve([
|
||||||
|
{{id:8,name:'frontend',color:'1d76db'}},
|
||||||
|
{{id:3,name:'P0',color:'d73a4a'}},
|
||||||
|
{{id:5,name:'critical',color:'b60205'}}
|
||||||
|
]);
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
capture.loadLabels('stackchain/api').then(labels => process.stdout.write(JSON.stringify({{calls,labels}})));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
output = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert output["calls"] == ["api/v1/repos/stackchain/api/labels"]
|
||||||
|
assert [label["name"] for label in output["labels"]] == ["P0", "critical", "frontend"]
|
||||||
|
|
||||||
|
|
||||||
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"},
|
||||||
|
|
@ -804,6 +833,8 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
|
||||||
assert 'id="create-issue-repository"' in html
|
assert 'id="create-issue-repository"' in html
|
||||||
assert 'id="create-issue-title"' in html and 'maxlength="255"' in html
|
assert 'id="create-issue-title"' in html and 'maxlength="255"' in html
|
||||||
assert 'id="create-issue-body"' in html and 'maxlength="10000"' in html
|
assert 'id="create-issue-body"' in html and 'maxlength="10000"' in html
|
||||||
|
assert 'id="create-issue-labels"' in html and 'aria-describedby="create-issue-label-status"' in html
|
||||||
|
assert 'id="create-issue-label-status"' in html and 'aria-live="polite"' in html
|
||||||
assert 'id="submit-new-issue"' in html
|
assert 'id="submit-new-issue"' in html
|
||||||
assert '.create-issue-sheet.open { display:flex; }' in html
|
assert '.create-issue-sheet.open { display:flex; }' in html
|
||||||
assert 'height:100dvh;' in html
|
assert 'height:100dvh;' in html
|
||||||
|
|
@ -812,6 +843,9 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
|
||||||
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
|
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
|
||||||
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
|
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
|
||||||
assert 'openIssueSheet(created' in html
|
assert 'openIssueSheet(created' in html
|
||||||
|
assert 'issueCapture.loadLabels(repository)' in html
|
||||||
|
assert "input[name=\"create-issue-label\"]:checked" in html
|
||||||
|
assert '.create-issue-label-option' in html and 'min-height:44px' in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user