Merge pull request 'Manage Today blockers offline and sync on reconnect' (#534) from timmy/533-offline-blocker-sync into main
This commit is contained in:
commit
175817e6d8
|
|
@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
|
|
@ -91,6 +91,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
draftFingerprint: String(message.draftFingerprint || ''),
|
||||
progressFingerprint: String(message.progressFingerprint || ''),
|
||||
} : {}),
|
||||
...(message.kind === 'issue-blocker' ? {
|
||||
blockerRepository: String(message.blockerRepository || ''),
|
||||
blockerNumber: Number(message.blockerNumber || 0),
|
||||
present: message.present === true,
|
||||
} : {}),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
|
|
@ -163,6 +168,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
if (item.kind === 'issue-close') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
|
|
@ -212,6 +220,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
});
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
} else if (item.kind === 'issue-blocker') {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: item.blockerRepository,
|
||||
number: item.blockerNumber,
|
||||
present: item.present,
|
||||
}),
|
||||
});
|
||||
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
|
||||
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
|
||||
Number(candidate?.number) === item.blockerNumber);
|
||||
if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) {
|
||||
throw new Error('Blocker change was not confirmed.');
|
||||
}
|
||||
} else {
|
||||
const body = item.kind === 'pull-review' ? {
|
||||
body: item.body,
|
||||
|
|
|
|||
|
|
@ -368,6 +368,24 @@ function createBackgroundIssueSync({
|
|||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers',
|
||||
options: {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: item.blockerRepository,
|
||||
number: item.blockerNumber,
|
||||
present: item.present === true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||
|
|
@ -612,6 +630,16 @@ function createBackgroundIssueSync({
|
|||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
const dependencies = Array.isArray(delivered?.dependencies) ? delivered.dependencies : [];
|
||||
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
|
||||
Number(candidate?.number) === item.blockerNumber);
|
||||
if (delivered?.number !== item.number || delivered?.dependencies_available !== true || present !== item.present) {
|
||||
const error = new Error('Blocker change was not confirmed.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await completeClaim(item, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
|
|
|
|||
|
|
@ -395,6 +395,9 @@
|
|||
backgroundSync: backgroundIssueSync,
|
||||
getOwnerLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
const queueOfflineIssueBlocker = createOfflineIssueBlocker({
|
||||
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
||||
});
|
||||
const notificationReadOutbox = createNotificationReadOutbox({
|
||||
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
||||
backgroundSync: backgroundIssueSync,
|
||||
|
|
@ -1342,7 +1345,7 @@
|
|||
const addButton = qs('#add-plan-preview');
|
||||
panel.hidden = false;
|
||||
list.innerHTML = '';
|
||||
qs('#manage-issue-blockers').hidden = !assignedIssue || selectedIssueOffline;
|
||||
qs('#manage-issue-blockers').hidden = !assignedIssue;
|
||||
qs('#start-unblocked-issue').hidden = true;
|
||||
if (!assignedIssue) qs('#issue-blocker-manager').hidden = true;
|
||||
if (assignedIssue) {
|
||||
|
|
@ -1403,11 +1406,20 @@
|
|||
}
|
||||
|
||||
async function mutateIssueBlocker(blocker, remove, button) {
|
||||
if (!selectedIssue || selectedIssueOffline) return;
|
||||
if (!selectedIssue) return;
|
||||
const item = selectedIssue;
|
||||
button.disabled = true;
|
||||
qs('#issue-blocker-status').textContent = remove ? 'Removing blocker…' : 'Adding blocker…';
|
||||
try {
|
||||
if (selectedIssueOffline) {
|
||||
await queueOfflineIssueBlocker(item, blocker, !remove);
|
||||
if (selectedIssue !== item) return;
|
||||
button.textContent = 'Queued';
|
||||
qs('#issue-blocker-status').textContent = remove ?
|
||||
'Blocker removal queued. Today remains blocked until Stackchain confirms delivery.' :
|
||||
'Blocker addition queued. Today readiness will update after Stackchain confirms delivery.';
|
||||
return { queued:true };
|
||||
}
|
||||
const result = remove ?
|
||||
await issueController.updateBlocker(selectedIssue, blocker, true) :
|
||||
await issueController.updateBlocker(selectedIssue, blocker, false);
|
||||
|
|
@ -1437,8 +1449,8 @@
|
|||
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 =>
|
||||
if (blocker) mutateIssueBlocker(blocker, false, button).then(outcome => {
|
||||
if (outcome?.queued || selectedIssueDetail?.dependencies?.some(candidate =>
|
||||
candidate.repository === blocker.repository && candidate.number === blocker.number
|
||||
)) {
|
||||
qs('#issue-blocker-manager').hidden = true;
|
||||
|
|
@ -1474,10 +1486,14 @@
|
|||
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 candidates = selectedIssueOffline ? workSession.items() :
|
||||
(await api('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10')).items;
|
||||
const existing = selectedIssueDetail?.dependencies || [];
|
||||
const items = (payload.items || []).filter(result =>
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
const items = (candidates || []).filter(result =>
|
||||
result.kind === 'issue' && result.state === 'open' &&
|
||||
(!selectedIssueOffline || (String(result.repository || '') + '#' + result.number + ' ' + String(result.title || ''))
|
||||
.toLowerCase().includes(normalizedQuery)) &&
|
||||
!(result.repository === selectedIssue?.repository && result.number === selectedIssue?.number) &&
|
||||
!existing.some(blocker => blocker.repository === result.repository && blocker.number === result.number)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -729,6 +729,7 @@
|
|||
<script src="static/issue-outbox.js"></script>
|
||||
<script src="static/authored-outbox.js"></script>
|
||||
<script src="static/offline-issue-close.js"></script>
|
||||
<script src="static/offline-issue-blocker.js"></script>
|
||||
<script src="static/notification-read-outbox.js"></script>
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
|
|
|
|||
21
frontend/offline-issue-blocker.js
Normal file
21
frontend/offline-issue-blocker.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
function createOfflineIssueBlocker({
|
||||
enqueueDurably,
|
||||
onQueued = () => {},
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
}) {
|
||||
return async function queueOfflineIssueBlocker(item, blocker, present) {
|
||||
const admission = await enqueueDurably({
|
||||
kind: 'issue-blocker',
|
||||
repository: String(item.repository || ''),
|
||||
number: Number(item.number || 0),
|
||||
blockerRepository: String(blocker.repository || ''),
|
||||
blockerNumber: Number(blocker.number || 0),
|
||||
present: present === true,
|
||||
operationId: String(createOperationId()).slice(0, 128),
|
||||
});
|
||||
onQueued(item, blocker, present === true, admission);
|
||||
return { admission };
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueBlocker;
|
||||
|
|
@ -22,6 +22,7 @@ const SHELL = [
|
|||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-issue-close.js',
|
||||
BASE + 'static/offline-issue-blocker.js',
|
||||
BASE + 'static/notification-read-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
|
|
|
|||
|
|
@ -1634,6 +1634,19 @@ async def mutate_assigned_issue_dependency(
|
|||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
|
||||
current = await issue_dependencies(repository, number)
|
||||
currently_present = any(
|
||||
item["repository"] == blocker_repository and item["number"] == blocker_number
|
||||
for item in current
|
||||
)
|
||||
if currently_present != remove:
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"dependencies_available": True,
|
||||
"dependencies": current,
|
||||
}
|
||||
|
||||
if not remove:
|
||||
candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}")
|
||||
if (
|
||||
|
|
@ -1643,13 +1656,6 @@ async def mutate_assigned_issue_dependency(
|
|||
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",
|
||||
|
|
|
|||
16
src/main.py
16
src/main.py
|
|
@ -511,6 +511,10 @@ class IssueBlockerUpdate(BaseModel):
|
|||
number: PositiveInt
|
||||
|
||||
|
||||
class IssueBlockerDesiredState(IssueBlockerUpdate):
|
||||
present: bool
|
||||
|
||||
|
||||
class PullReviewComment(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
|
|
@ -3269,6 +3273,18 @@ async def remove_assigned_issue_blocker(
|
|||
return await _mutate_assigned_issue_blocker(update, owner, repo, number, True)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers")
|
||||
async def set_assigned_issue_blocker_state(
|
||||
update: IssueBlockerDesiredState,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
return await _mutate_assigned_issue_blocker(
|
||||
update, owner, repo, number, not update.present
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content")
|
||||
async def update_assigned_issue_content(
|
||||
update: IssueContentUpdate,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,41 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
|||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const calls = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
fetchJson: async (url, options) => {{
|
||||
calls.push({{url,method:options.method,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
return {{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
|
||||
}},
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
kind:'issue-blocker',repository:'stackchain/dashboard',number:17,operationId:'blocker-op',
|
||||
blockerRepository:'stackchain/api',blockerNumber:9,present:false,
|
||||
}});
|
||||
const restored = createAuthoredOutbox({{storage}}).list()[0];
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{queued,restored,calls,result,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["blockerRepository"] == "stackchain/api"
|
||||
assert output["queued"]["blockerNumber"] == 9
|
||||
assert output["queued"]["present"] is False
|
||||
assert output["restored"]["present"] is False
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
"method": "PATCH",
|
||||
"key": "blocker-op",
|
||||
"body": {"repository": "stackchain/api", "number": 9, "present": False},
|
||||
}]
|
||||
assert output["result"]["confirmed"][0]["dependencies"] == []
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_exposes_sending_then_preserves_transient_attempt_details():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
|
|
|||
|
|
@ -64,6 +64,54 @@ const fetchJson = async (url, options = {{}}) => {{
|
|||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item = {{
|
||||
id:'blocker-1',operationId:'blocker-1',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',number:17,blockerRepository:'stackchain/api',blockerNumber:9,present:false,
|
||||
}};
|
||||
const state={{calls:[],completed:[]}};
|
||||
const store={{
|
||||
claimNext:async()=>item?{{...item}}:null,
|
||||
complete:async id=>{{state.completed.push(id);item=null;}},
|
||||
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
state.calls.push({{url,method:options.method,key:options.headers?.['Idempotency-Key']||'',body:options.body?JSON.parse(options.body):null}});
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
return{{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
|
||||
}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["calls"][1] == {
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
"method": "PATCH",
|
||||
"key": "blocker-1",
|
||||
"body": {"repository": "stackchain/api", "number": 9, "present": False},
|
||||
}
|
||||
assert output["state"]["completed"] == ["blocker-1"]
|
||||
assert output["result"]["confirmed"][0]["dependencies"] == []
|
||||
|
||||
|
||||
def test_closed_app_sync_keeps_unconfirmed_blocker_change_actionable():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item={{id:'blocker-2',operationId:'blocker-2',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',repository:'o/r',number:1,blockerRepository:'o/b',blockerNumber:2,present:false}};
|
||||
const state={{completed:[],failed:[]}};
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,complete:async id=>{{state.completed.push(id);item=null;}},release:async()=>{{}},fail:async(id,message)=>{{state.failed.push([id,message]);item=null;}},countBlocked:async()=>0}};
|
||||
const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:1,dependencies_available:true,dependencies:[{{repository:'o/b',number:2}}]}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["completed"] == []
|
||||
assert output["state"]["failed"] == [["blocker-2", "Blocker change was not confirmed."]]
|
||||
assert output["result"]["attention"] == 1
|
||||
|
||||
|
||||
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
|
|
|||
|
|
@ -1977,6 +1977,32 @@ async def test_assigned_issue_blocker_route_returns_canonical_dependencies(monke
|
|||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_blocker_patch_converges_to_desired_state(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}],
|
||||
}
|
||||
|
||||
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.patch(
|
||||
"/api/v1/repos/stackchain/dashboard/issues/17/blockers",
|
||||
headers={"Idempotency-Key": "offline-blocker-1"},
|
||||
json={"repository": "stackchain/api", "number": 9, "present": False},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["dependencies"] == []
|
||||
assert calls == [("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):
|
||||
|
|
@ -2037,14 +2063,49 @@ async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmat
|
|||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_dependency_retry_returns_canonical_state_without_duplicate_mutation():
|
||||
mutations = []
|
||||
dependency = {
|
||||
"number": 9, "state": "open", "title": "Restore API",
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"html_url": "https://forge.example/stackchain/api/issues/9",
|
||||
}
|
||||
|
||||
async def handler(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"):
|
||||
return httpx.Response(200, json=[dependency])
|
||||
if path.endswith("/dependencies"):
|
||||
mutations.append(request.method)
|
||||
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()
|
||||
|
||||
assert mutations == []
|
||||
assert result["dependencies"][0]["number"] == 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
|
||||
|
|
|
|||
78
tests/test_offline_issue_blocker.py
Normal file
78
tests/test_offline_issue_blocker.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "offline-issue-blocker.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_offline_blocker_reports_queued_only_after_durable_admission():
|
||||
script = f"""
|
||||
const createOfflineIssueBlocker=require({json.dumps(str(MODULE))});
|
||||
const events=[];
|
||||
const queue=createOfflineIssueBlocker({{
|
||||
createOperationId:()=> 'blocker-op',
|
||||
enqueueDurably:async message=>{{events.push(['admit',message]);return{{item:message,durability:'background'}};}},
|
||||
onQueued:(item,blocker,present,admission)=>events.push(['queued',item.number,blocker.number,present,admission.durability]),
|
||||
}});
|
||||
(async()=>{{
|
||||
const result=await queue(
|
||||
{{repository:'stackchain/dashboard',number:17}},
|
||||
{{repository:'stackchain/api',number:9,title:'Restore API'}},
|
||||
false
|
||||
);
|
||||
process.stdout.write(JSON.stringify({{events,result}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["events"] == [
|
||||
["admit", {
|
||||
"kind": "issue-blocker", "repository": "stackchain/dashboard", "number": 17,
|
||||
"blockerRepository": "stackchain/api", "blockerNumber": 9,
|
||||
"present": False, "operationId": "blocker-op",
|
||||
}],
|
||||
["queued", 17, 9, False, "background"],
|
||||
]
|
||||
assert output["result"]["admission"]["durability"] == "background"
|
||||
|
||||
|
||||
def test_offline_blocker_does_not_report_queued_when_durable_admission_fails():
|
||||
script = f"""
|
||||
const createOfflineIssueBlocker=require({json.dumps(str(MODULE))});
|
||||
let queued=false;
|
||||
const queue=createOfflineIssueBlocker({{
|
||||
enqueueDurably:async()=>{{throw new Error('IndexedDB unavailable');}},
|
||||
onQueued:()=>queued=true,
|
||||
}});
|
||||
queue({{repository:'o/r',number:1}},{{repository:'o/b',number:2}},true)
|
||||
.then(()=>process.stdout.write(JSON.stringify({{queued}})))
|
||||
.catch(error=>process.stdout.write(JSON.stringify({{queued,error:error.message}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"queued": False, "error": "IndexedDB unavailable"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_issue_exposes_offline_blocker_queue_in_mobile_sheet():
|
||||
html = await dashboard()
|
||||
dashboard_js = (MODULE.parent / "dashboard.js").read_text()
|
||||
service_worker = (MODULE.parent / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/offline-issue-blocker.js"></script>' in html
|
||||
assert "BASE + 'static/offline-issue-blocker.js'" in service_worker
|
||||
assert "const queueOfflineIssueBlocker = createOfflineIssueBlocker({" in dashboard_js
|
||||
assert "qs('#manage-issue-blockers').hidden = !assignedIssue;" in dashboard_js
|
||||
assert "await queueOfflineIssueBlocker(item, blocker, !remove);" in dashboard_js
|
||||
assert "Blocker removal queued. Today remains blocked until Stackchain confirms delivery." in dashboard_js
|
||||
assert "const candidates = selectedIssueOffline ? workSession.items()" in dashboard_js
|
||||
|
|
@ -429,6 +429,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/offline-issue-close.js",
|
||||
"/dashboard/static/offline-issue-blocker.js",
|
||||
"/dashboard/static/notification-read-outbox.js",
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/offline-today.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user