Manage issue blockers without leaving Today #532
|
|
@ -278,6 +278,14 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.issue-blocker-list { display:grid; gap:8px; }
|
||||
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
|
||||
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
|
||||
.issue-blocker-row { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:start; }
|
||||
.issue-blocker-row .issue-blocker { min-width:0; }
|
||||
.issue-blocker-remove { min-height:44px; white-space:normal; }
|
||||
.issue-blocker-manager { display:grid; gap:8px; margin-top:10px; min-width:0; }
|
||||
.issue-blocker-manager :is(input,button) { min-height:44px; width:100%; max-width:100%; box-sizing:border-box; }
|
||||
.issue-blocker-manager [role="listbox"] { display:grid; gap:6px; max-height:240px; overflow:auto; }
|
||||
.issue-blocker-manager [role="option"] { text-align:left; overflow-wrap:anywhere; }
|
||||
#manage-issue-blockers, #start-unblocked-issue { min-height:44px; width:100%; margin-top:10px; }
|
||||
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
|
||||
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
|
||||
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
|
|
@ -468,6 +476,8 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-blocker-row { grid-template-columns:1fr; }
|
||||
.issue-blocker-remove { width:100%; }
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
|
||||
#cmd-palette.open { display:flex; flex-direction:column; }
|
||||
|
|
|
|||
|
|
@ -123,6 +123,8 @@
|
|||
let selectedIssue = null;
|
||||
let selectedIssueOffline = false;
|
||||
let selectedIssueDetail = null;
|
||||
let issueBlockerCandidates = [];
|
||||
let issueBlockerSearchTimer = null;
|
||||
let issueConversation = null;
|
||||
let issueTrigger = null;
|
||||
let selectedPull = null;
|
||||
|
|
@ -1332,13 +1334,42 @@
|
|||
|
||||
function renderPlanIssueDependencies(detail, loading = false) {
|
||||
const preview = planTodayPreview.snapshot();
|
||||
if (!preview.open || preview.item?.kind !== 'issue') return;
|
||||
const assignedIssue = Boolean(selectedIssue && selectedIssueDetail === detail);
|
||||
if (!assignedIssue && (!preview.open || preview.item?.kind !== 'issue')) return;
|
||||
const panel = qs('#issue-blockers');
|
||||
const list = qs('#issue-blocker-list');
|
||||
const status = qs('#issue-blocker-status');
|
||||
const addButton = qs('#add-plan-preview');
|
||||
panel.hidden = false;
|
||||
list.innerHTML = '';
|
||||
qs('#manage-issue-blockers').hidden = !assignedIssue || selectedIssueOffline;
|
||||
qs('#start-unblocked-issue').hidden = true;
|
||||
if (!assignedIssue) qs('#issue-blocker-manager').hidden = true;
|
||||
if (assignedIssue) {
|
||||
const available = detail?.dependencies_available === true;
|
||||
const dependencies = Array.isArray(detail?.dependencies) ? detail.dependencies : [];
|
||||
list.innerHTML = dependencies.map((blocker, index) =>
|
||||
'<div class="issue-blocker-row"><a class="issue-blocker" href="' + escAttr(blocker.url || '#') +
|
||||
'" target="_blank" rel="noopener noreferrer"><strong>' + escapeHtml(blocker.repository + '#' + blocker.number) +
|
||||
' · ' + escapeHtml(blocker.title || 'Untitled blocker') + '</strong><span class="small">State: ' +
|
||||
escapeHtml(blocker.state || 'open') + '</span></a><button class="issue-blocker-remove" type="button" data-remove-blocker-index="' +
|
||||
index + '">Remove blocker</button></div>'
|
||||
).join('');
|
||||
list.querySelectorAll('[data-remove-blocker-index]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const blocker = dependencies[Number(button.dataset.removeBlockerIndex)];
|
||||
if (blocker && window.confirm('Remove ' + blocker.repository + '#' + blocker.number + ' as a blocker?')) {
|
||||
mutateIssueBlocker(blocker, true, button);
|
||||
}
|
||||
});
|
||||
});
|
||||
status.textContent = available ? (dependencies.length ?
|
||||
dependencies.length + (dependencies.length === 1 ? ' unresolved blocker.' : ' unresolved blockers.') :
|
||||
'No unresolved blockers. Add one without leaving this issue.') :
|
||||
'Blocker status unavailable. Reload before changing relationships.';
|
||||
qs('#manage-issue-blockers').disabled = !available;
|
||||
return;
|
||||
}
|
||||
addButton.disabled = loading;
|
||||
addButton.dataset.planOverride = '';
|
||||
if (loading) {
|
||||
|
|
@ -1371,6 +1402,98 @@
|
|||
addButton.textContent = 'Add to Today & back';
|
||||
}
|
||||
|
||||
async function mutateIssueBlocker(blocker, remove, button) {
|
||||
if (!selectedIssue || selectedIssueOffline) return;
|
||||
const item = selectedIssue;
|
||||
button.disabled = true;
|
||||
qs('#issue-blocker-status').textContent = remove ? 'Removing blocker…' : 'Adding blocker…';
|
||||
try {
|
||||
const result = remove ?
|
||||
await issueController.updateBlocker(selectedIssue, blocker, true) :
|
||||
await issueController.updateBlocker(selectedIssue, blocker, false);
|
||||
if (selectedIssue !== item) return;
|
||||
selectedIssueDetail = { ...selectedIssueDetail, ...result };
|
||||
renderPlanIssueDependencies(selectedIssueDetail);
|
||||
if (remove && result.dependencies.length === 0 && workSession.checkpointed(item)) {
|
||||
qs('#issue-blocker-status').textContent = 'All blockers cleared. This Today item is ready.';
|
||||
qs('#start-unblocked-issue').hidden = false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (selectedIssue !== item) return;
|
||||
qs('#issue-blocker-status').textContent = error.message + ' The previous blocker list is unchanged; reload before retrying.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function renderIssueBlockerCandidates(items) {
|
||||
issueBlockerCandidates = items;
|
||||
const results = qs('#issue-blocker-results');
|
||||
results.innerHTML = items.map((result, index) =>
|
||||
'<button type="button" role="option" data-blocker-result="' + index + '"><strong>' +
|
||||
escapeHtml(result.repository + '#' + result.number) + '</strong><span class="small">' +
|
||||
escapeHtml(result.title || 'Untitled issue') + '</span></button>'
|
||||
).join('');
|
||||
results.querySelectorAll('[data-blocker-result]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const blocker = issueBlockerCandidates[Number(button.dataset.blockerResult)];
|
||||
if (blocker) mutateIssueBlocker(blocker, false, button).then(() => {
|
||||
if (selectedIssueDetail?.dependencies?.some(candidate =>
|
||||
candidate.repository === blocker.repository && candidate.number === blocker.number
|
||||
)) {
|
||||
qs('#issue-blocker-manager').hidden = true;
|
||||
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
qs('#manage-issue-blockers').addEventListener('click', () => {
|
||||
qs('#issue-blocker-manager').hidden = false;
|
||||
qs('#issue-blocker-search').setAttribute('aria-expanded', 'true');
|
||||
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
|
||||
qs('#issue-blocker-search').focus();
|
||||
});
|
||||
qs('#cancel-issue-blocker').addEventListener('click', () => {
|
||||
qs('#issue-blocker-manager').hidden = true;
|
||||
qs('#issue-blocker-search').setAttribute('aria-expanded', 'false');
|
||||
qs('#manage-issue-blockers').focus();
|
||||
});
|
||||
qs('#issue-blocker-search').addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') qs('#cancel-issue-blocker').click();
|
||||
});
|
||||
qs('#issue-blocker-search').addEventListener('input', event => {
|
||||
clearTimeout(issueBlockerSearchTimer);
|
||||
const query = event.target.value.trim();
|
||||
if (query.length < 2) {
|
||||
renderIssueBlockerCandidates([]);
|
||||
qs('#issue-blocker-search-status').textContent = 'Enter at least 2 characters.';
|
||||
return;
|
||||
}
|
||||
qs('#issue-blocker-search-status').textContent = 'Searching open issues…';
|
||||
issueBlockerSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const payload = await api('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10');
|
||||
const existing = selectedIssueDetail?.dependencies || [];
|
||||
const items = (payload.items || []).filter(result =>
|
||||
result.kind === 'issue' && result.state === 'open' &&
|
||||
!(result.repository === selectedIssue?.repository && result.number === selectedIssue?.number) &&
|
||||
!existing.some(blocker => blocker.repository === result.repository && blocker.number === result.number)
|
||||
);
|
||||
renderIssueBlockerCandidates(items);
|
||||
qs('#issue-blocker-search-status').textContent = items.length ?
|
||||
items.length + (items.length === 1 ? ' open issue found.' : ' open issues found.') : 'No eligible open issues found.';
|
||||
} catch (error) {
|
||||
renderIssueBlockerCandidates([]);
|
||||
qs('#issue-blocker-search-status').textContent = error.message + ' Retry your search.';
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
qs('#start-unblocked-issue').addEventListener('click', () => {
|
||||
if (selectedIssue) todayReadiness.run('start', workSession.items(), selectedIssue);
|
||||
});
|
||||
|
||||
let addPlanPreviewOnReturn = false;
|
||||
let addPlanPreviewOverride = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -307,6 +307,16 @@
|
|||
<h2 id="issue-blockers-title">Blocked by</h2>
|
||||
<div class="issue-blocker-list" id="issue-blocker-list"></div>
|
||||
<div id="issue-blocker-status" class="small" aria-live="polite"></div>
|
||||
<button id="manage-issue-blockers" type="button" hidden>Add blocker</button>
|
||||
<button id="start-unblocked-issue" type="button" hidden>Start now</button>
|
||||
<div class="issue-blocker-manager" id="issue-blocker-manager" hidden>
|
||||
<label for="issue-blocker-search">Search open issues</label>
|
||||
<input id="issue-blocker-search" type="search" maxlength="100" autocomplete="off"
|
||||
role="combobox" aria-controls="issue-blocker-results" aria-expanded="false" />
|
||||
<div id="issue-blocker-results" role="listbox" aria-label="Open issue results"></div>
|
||||
<div id="issue-blocker-search-status" class="small" aria-live="polite"></div>
|
||||
<button id="cancel-issue-blocker" type="button">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
<h2>Full conversation</h2>
|
||||
<div id="issue-comments"></div>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
|||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
let milestoneRequest = null;
|
||||
let blockerRequest = null;
|
||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
||||
|
|
@ -67,6 +68,24 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
|||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
updateBlocker(item, blocker, remove = false) {
|
||||
if (blockerRequest) return blockerRequest;
|
||||
blockerRequest = fetchJson(issuePath(item) + '/blockers', {
|
||||
method: remove ? 'DELETE' : 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ repository: blocker.repository, number: blocker.number }),
|
||||
}).then(result => {
|
||||
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
|
||||
const present = dependencies.some(candidate =>
|
||||
candidate?.repository === blocker.repository && Number(candidate?.number) === Number(blocker.number)
|
||||
);
|
||||
if (result?.number !== item.number || result?.dependencies_available !== true || present === remove) {
|
||||
throw new Error('Blocker change was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { blockerRequest = null; });
|
||||
return blockerRequest;
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
|
|
|
|||
|
|
@ -169,6 +169,10 @@ class IssueEditConflictError(ValueError):
|
|||
"""Raised when an issue changed after the editor loaded it."""
|
||||
|
||||
|
||||
class IssueDependencyInvalidError(ValueError):
|
||||
"""Raised when a requested blocker relationship is not valid."""
|
||||
|
||||
|
||||
def _auth() -> dict[str, str]:
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
if GITEA_TOKEN:
|
||||
|
|
@ -1609,6 +1613,67 @@ async def issue_dependencies(repository: str, number: int, limit: int = 20) -> l
|
|||
return dependencies
|
||||
|
||||
|
||||
async def mutate_assigned_issue_dependency(
|
||||
repository: str,
|
||||
number: int,
|
||||
blocker_repository: str,
|
||||
blocker_number: int,
|
||||
remove: bool = False,
|
||||
) -> dict:
|
||||
"""Add or remove one prerequisite and return the canonical open dependency set."""
|
||||
if (repository, number) == (blocker_repository, blocker_number):
|
||||
raise IssueDependencyInvalidError("an issue cannot block itself")
|
||||
|
||||
login, source = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
if (
|
||||
source.get("state") != "open"
|
||||
or isinstance(source.get("pull_request"), dict)
|
||||
or not _login_in_users(login, source.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
|
||||
if not remove:
|
||||
candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}")
|
||||
if (
|
||||
not isinstance(candidate, dict)
|
||||
or candidate.get("number") != blocker_number
|
||||
or candidate.get("state") != "open"
|
||||
or isinstance(candidate.get("pull_request"), dict)
|
||||
):
|
||||
raise IssueDependencyInvalidError("blocker must be an accessible open issue")
|
||||
current = await issue_dependencies(repository, number)
|
||||
if any(
|
||||
item["repository"] == blocker_repository and item["number"] == blocker_number
|
||||
for item in current
|
||||
):
|
||||
raise IssueDependencyInvalidError("that issue is already a blocker")
|
||||
|
||||
owner, repo = blocker_repository.split("/", 1)
|
||||
response = await _get_client().request(
|
||||
"DELETE" if remove else "POST",
|
||||
f"/api/v1/repos/{repository}/issues/{number}/dependencies",
|
||||
headers=_auth(),
|
||||
json={"owner": owner, "repo": repo, "index": blocker_number},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
confirmed = await issue_dependencies(repository, number)
|
||||
present = any(
|
||||
item["repository"] == blocker_repository and item["number"] == blocker_number
|
||||
for item in confirmed
|
||||
)
|
||||
if present == remove:
|
||||
raise ValueError("Gitea did not confirm the dependency change")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"dependencies_available": True,
|
||||
"dependencies": confirmed,
|
||||
}
|
||||
|
||||
|
||||
async def update_assigned_issue(
|
||||
repository: str,
|
||||
number: int,
|
||||
|
|
|
|||
56
src/main.py
56
src/main.py
|
|
@ -502,6 +502,15 @@ class IssueHandoff(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class IssueBlockerUpdate(BaseModel):
|
||||
repository: str = Field(
|
||||
min_length=3,
|
||||
max_length=255,
|
||||
pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
|
||||
)
|
||||
number: PositiveInt
|
||||
|
||||
|
||||
class PullReviewComment(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
|
|
@ -3213,6 +3222,53 @@ async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(g
|
|||
)
|
||||
|
||||
|
||||
async def _mutate_assigned_issue_blocker(
|
||||
update: IssueBlockerUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
remove: bool,
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.mutate_assigned_issue_dependency(
|
||||
f"{owner}/{repo}", number, update.repository, update.number, remove
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
except gitea_proxy.IssueDependencyInvalidError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=422)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The blocker change could not be confirmed. Reload the issue before retrying."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers")
|
||||
async def add_assigned_issue_blocker(
|
||||
update: IssueBlockerUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
return await _mutate_assigned_issue_blocker(update, owner, repo, number, False)
|
||||
|
||||
|
||||
@app.delete("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers")
|
||||
async def remove_assigned_issue_blocker(
|
||||
update: IssueBlockerUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
return await _mutate_assigned_issue_blocker(update, owner, repo, number, True)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content")
|
||||
async def update_assigned_issue_content(
|
||||
update: IssueContentUpdate,
|
||||
|
|
|
|||
|
|
@ -1939,3 +1939,132 @@ async def test_gitea_update_issue_labels_rejects_unconfirmed_label_set():
|
|||
await gitea_proxy.update_issue_labels("stackchain/api", 7, [3])
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_blocker_route_returns_canonical_dependencies(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def mutate(repository, number, blocker_repository, blocker_number, remove=False):
|
||||
calls.append((repository, number, blocker_repository, blocker_number, remove))
|
||||
return {
|
||||
"repository": repository, "number": number, "dependencies_available": True,
|
||||
"dependencies": [] if remove else [{
|
||||
"repository": blocker_repository, "number": blocker_number,
|
||||
"title": "Restore API", "state": "open",
|
||||
}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
added = await client.post(
|
||||
"/api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
json={"repository": "stackchain/api", "number": 9},
|
||||
)
|
||||
removed = await client.request(
|
||||
"DELETE", "/api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
json={"repository": "stackchain/api", "number": 9},
|
||||
)
|
||||
|
||||
assert [added.status_code, removed.status_code] == [200, 200]
|
||||
assert added.headers["cache-control"] == "no-store"
|
||||
assert added.json()["dependencies"][0]["number"] == 9
|
||||
assert removed.json()["dependencies"] == []
|
||||
assert calls == [
|
||||
("stackchain/dashboard", 17, "stackchain/api", 9, False),
|
||||
("stackchain/dashboard", 17, "stackchain/api", 9, True),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_blocker_route_rejects_invalid_candidate(monkeypatch):
|
||||
async def mutate(*_args, **_kwargs):
|
||||
raise gitea_proxy.IssueDependencyInvalidError("blocker must be an open issue")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
|
||||
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/dashboard/issues/17/blockers",
|
||||
json={"repository": "stackchain/api", "number": 9},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {"error": "blocker must be an open issue"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmation():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
path = request.url.path
|
||||
if path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if path == "/api/v1/repos/stackchain/dashboard/issues/17":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
|
||||
})
|
||||
if path == "/api/v1/repos/stackchain/api/issues/9":
|
||||
return httpx.Response(200, json={"number": 9, "state": "open"})
|
||||
if request.method == "GET" and path.endswith("/dependencies"):
|
||||
mutated = any(r.method == "POST" for r in requests)
|
||||
return httpx.Response(200, json=[{
|
||||
"number": 9, "state": "open", "title": "Restore API",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/9",
|
||||
}] if mutated else [])
|
||||
if request.method == "POST" and path.endswith("/dependencies"):
|
||||
return httpx.Response(201, json={})
|
||||
raise AssertionError((request.method, path))
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.mutate_assigned_issue_dependency(
|
||||
"stackchain/dashboard", 17, "stackchain/api", 9
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
mutation = next(request for request in requests if request.method == "POST")
|
||||
assert mutation.url.path == "/api/v1/repos/stackchain/dashboard/issues/17/dependencies"
|
||||
assert json.loads(mutation.content) == {"owner": "stackchain", "repo": "api", "index": 9}
|
||||
assert result["dependencies"] == [{
|
||||
"repository": "stackchain/api", "number": 9, "title": "Restore API",
|
||||
"state": "open", "url": "https://forge.example/stackchain/api/issues/9",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("candidate,blocker_repository,blocker_number,existing", [
|
||||
({"number": 17, "state": "open"}, "stackchain/dashboard", 17, []),
|
||||
({"number": 9, "state": "closed"}, "stackchain/api", 9, []),
|
||||
({"number": 9, "state": "open", "pull_request": {}}, "stackchain/api", 9, []),
|
||||
({"number": 9, "state": "open"}, "stackchain/api", 9, [
|
||||
{"number": 9, "state": "open", "repository": {"full_name": "stackchain/api"}},
|
||||
]),
|
||||
])
|
||||
async def test_gitea_dependency_rejects_invalid_candidates(
|
||||
candidate, blocker_repository, blocker_number, existing
|
||||
):
|
||||
async def handler(request):
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path == "/api/v1/repos/stackchain/dashboard/issues/17":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "assignees": [{"login": "timmy"}],
|
||||
})
|
||||
if request.url.path.endswith("/dependencies"):
|
||||
return httpx.Response(200, json=existing)
|
||||
return httpx.Response(200, json=candidate)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(gitea_proxy.IssueDependencyInvalidError):
|
||||
await gitea_proxy.mutate_assigned_issue_dependency(
|
||||
"stackchain/dashboard", 17, blocker_repository, blocker_number
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
|
|
|||
|
|
@ -811,6 +811,77 @@ controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
|
|||
assert output["result"] == [{"login": "alex", "name": "Alexander"}]
|
||||
|
||||
|
||||
def test_issue_blocker_mutation_is_single_flight_and_requires_canonical_confirmation():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let calls = [];
|
||||
let finish;
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
fetchJson:(url, options) => {{ calls.push({{url, options}}); return new Promise(resolve => finish = resolve); }},
|
||||
}});
|
||||
const item = {{repository:'stackchain/dashboard', number:17}};
|
||||
const blocker = {{repository:'stackchain/api', number:9}};
|
||||
const first = controller.updateBlocker(item, blocker, false);
|
||||
const duplicate = controller.updateBlocker(item, blocker, false);
|
||||
finish({{repository:item.repository, number:item.number, dependencies_available:true,
|
||||
dependencies:[{{...blocker,title:'Restore API',state:'open'}}]}});
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls:calls.map(call => ({{url:call.url,method:call.options.method,body:JSON.parse(call.options.body)}})),
|
||||
same:first === duplicate, result:results[0]
|
||||
}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["same"] is True
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
"method": "POST",
|
||||
"body": {"repository": "stackchain/api", "number": 9},
|
||||
}]
|
||||
assert output["result"]["dependencies"][0]["number"] == 9
|
||||
|
||||
|
||||
def test_issue_blocker_removal_rejects_unconfirmed_canonical_state():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const controller = createIssueSheet({{
|
||||
storage:null,
|
||||
fetchJson:async () => ({{dependencies_available:true,
|
||||
dependencies:[{{repository:'stackchain/api',number:9,state:'open'}}]}}),
|
||||
}});
|
||||
controller.updateBlocker(
|
||||
{{repository:'stackchain/dashboard',number:17}},
|
||||
{{repository:'stackchain/api',number:9}}, true
|
||||
).then(() => process.stdout.write('unexpected'))
|
||||
.catch(error => process.stdout.write(error.message));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
assert result.stdout == "Blocker change was not confirmed."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_sheet_manages_blockers_with_search_and_touch_safe_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="manage-issue-blockers"' in html
|
||||
assert 'id="issue-blocker-search" type="search"' in html
|
||||
assert 'id="issue-blocker-results" role="listbox"' in html
|
||||
assert 'id="cancel-issue-blocker"' in html
|
||||
assert "issueController.updateBlocker(selectedIssue, blocker, false)" in html
|
||||
assert "issueController.updateBlocker(selectedIssue, blocker, true)" in html
|
||||
assert "api('api/v1/search?q='" in html
|
||||
assert "result.kind === 'issue' && result.state === 'open'" in html
|
||||
assert "renderPlanIssueDependencies(selectedIssueDetail)" in html
|
||||
assert ".issue-blocker-manager :is(input,button) { min-height:44px;" in html
|
||||
assert "width:100%; max-width:100%; box-sizing:border-box" in html
|
||||
assert "overflow-wrap:anywhere" in html
|
||||
|
||||
|
||||
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user