Compare commits
No commits in common. "bca27f03303f24759d73a6797f2bcbde1dfffb06" and "ce26f54618e1f7358b941d0bb4417072e316b570" have entirely different histories.
bca27f0330
...
ce26f54618
|
|
@ -16,15 +16,12 @@ 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,
|
||||||
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
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. The New issue sheet can optionally select an open
|
creation and assignment permission. Issue capture and authored mobile actions (issue
|
||||||
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,25 +15,18 @@ 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: ''};
|
||||||
const draft = {
|
return {
|
||||||
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: ''};
|
||||||
}
|
}
|
||||||
|
|
@ -52,12 +45,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
body: String(draft?.body || ''),
|
body: String(draft?.body || ''),
|
||||||
labelIds: safeLabelIds(draft?.labelIds),
|
labelIds: safeLabelIds(draft?.labelIds),
|
||||||
};
|
};
|
||||||
const milestoneId = safeMilestoneId(draft?.milestoneId);
|
const unchanged = ['repository', 'title', 'body'].every(key => previous[key] === safe[key]) &&
|
||||||
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;
|
||||||
|
|
@ -85,13 +73,6 @@ 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);
|
||||||
|
|
@ -107,8 +88,6 @@ 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();
|
||||||
|
|
@ -117,7 +96,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { saveDraft, loadDraft, loadLabels, loadMilestones, submit };
|
return { saveDraft, loadDraft, loadLabels, 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, .create-issue-form input[type="date"] { 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-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,15 +519,6 @@ 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>
|
||||||
|
|
@ -1766,8 +1757,6 @@ 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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1794,30 +1783,6 @@ 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);
|
||||||
|
|
@ -1830,9 +1795,7 @@ 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');
|
||||||
|
|
@ -2404,16 +2367,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
saveIssueCaptureDraft();
|
saveIssueCaptureDraft();
|
||||||
closeCreateIssueSheet();
|
closeCreateIssueSheet();
|
||||||
});
|
});
|
||||||
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].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 => {
|
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 = {
|
||||||
|
|
@ -2421,8 +2382,6 @@ 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,16 +751,10 @@ 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(),
|
||||||
|
|
@ -781,22 +775,6 @@ 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"],
|
||||||
|
|
@ -813,8 +791,6 @@ 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,7 +4,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -152,12 +151,6 @@ 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
|
||||||
|
|
@ -172,13 +165,6 @@ 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)
|
||||||
|
|
@ -1453,27 +1439,6 @@ 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
|
||||||
)
|
)
|
||||||
|
|
@ -1488,8 +1453,6 @@ 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,55 +373,6 @@ 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
|
||||||
|
|
@ -679,26 +630,6 @@ 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 = []
|
||||||
|
|
@ -771,27 +702,6 @@ 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,19 +521,6 @@ 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()
|
||||||
|
|
@ -1258,50 +1245,6 @@ 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