feat: sync Today blockers offline (Closes #533)
This commit is contained in:
parent
ee36b8824a
commit
fff7bccf08
|
|
@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||||
);
|
);
|
||||||
const pending = new Map();
|
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) {
|
function reviewFingerprint(message) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
|
|
@ -91,6 +91,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
draftFingerprint: String(message.draftFingerprint || ''),
|
draftFingerprint: String(message.draftFingerprint || ''),
|
||||||
progressFingerprint: String(message.progressFingerprint || ''),
|
progressFingerprint: String(message.progressFingerprint || ''),
|
||||||
} : {}),
|
} : {}),
|
||||||
|
...(message.kind === 'issue-blocker' ? {
|
||||||
|
blockerRepository: String(message.blockerRepository || ''),
|
||||||
|
blockerNumber: Number(message.blockerNumber || 0),
|
||||||
|
present: message.present === true,
|
||||||
|
} : {}),
|
||||||
};
|
};
|
||||||
items.push(item);
|
items.push(item);
|
||||||
write(items, mirror);
|
write(items, mirror);
|
||||||
|
|
@ -163,6 +168,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
if (item.kind === 'issue-close') {
|
if (item.kind === 'issue-close') {
|
||||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/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') {
|
if (item.kind === 'pull-review') {
|
||||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/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 },
|
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||||
});
|
});
|
||||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
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 {
|
} else {
|
||||||
const body = item.kind === 'pull-review' ? {
|
const body = item.kind === 'pull-review' ? {
|
||||||
body: item.body,
|
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') {
|
if (item.kind === 'pull-review') {
|
||||||
return {
|
return {
|
||||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||||
|
|
@ -612,6 +630,16 @@ function createBackgroundIssueSync({
|
||||||
error.status = 422;
|
error.status = 422;
|
||||||
throw error;
|
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);
|
await completeClaim(item, delivered);
|
||||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,9 @@
|
||||||
backgroundSync: backgroundIssueSync,
|
backgroundSync: backgroundIssueSync,
|
||||||
getOwnerLogin: () => confirmedOwnerLogin,
|
getOwnerLogin: () => confirmedOwnerLogin,
|
||||||
});
|
});
|
||||||
|
const queueOfflineIssueBlocker = createOfflineIssueBlocker({
|
||||||
|
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
||||||
|
});
|
||||||
const notificationReadOutbox = createNotificationReadOutbox({
|
const notificationReadOutbox = createNotificationReadOutbox({
|
||||||
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
||||||
backgroundSync: backgroundIssueSync,
|
backgroundSync: backgroundIssueSync,
|
||||||
|
|
@ -1342,7 +1345,7 @@
|
||||||
const addButton = qs('#add-plan-preview');
|
const addButton = qs('#add-plan-preview');
|
||||||
panel.hidden = false;
|
panel.hidden = false;
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
qs('#manage-issue-blockers').hidden = !assignedIssue || selectedIssueOffline;
|
qs('#manage-issue-blockers').hidden = !assignedIssue;
|
||||||
qs('#start-unblocked-issue').hidden = true;
|
qs('#start-unblocked-issue').hidden = true;
|
||||||
if (!assignedIssue) qs('#issue-blocker-manager').hidden = true;
|
if (!assignedIssue) qs('#issue-blocker-manager').hidden = true;
|
||||||
if (assignedIssue) {
|
if (assignedIssue) {
|
||||||
|
|
@ -1403,11 +1406,20 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mutateIssueBlocker(blocker, remove, button) {
|
async function mutateIssueBlocker(blocker, remove, button) {
|
||||||
if (!selectedIssue || selectedIssueOffline) return;
|
if (!selectedIssue) return;
|
||||||
const item = selectedIssue;
|
const item = selectedIssue;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
qs('#issue-blocker-status').textContent = remove ? 'Removing blocker…' : 'Adding blocker…';
|
qs('#issue-blocker-status').textContent = remove ? 'Removing blocker…' : 'Adding blocker…';
|
||||||
try {
|
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 ?
|
const result = remove ?
|
||||||
await issueController.updateBlocker(selectedIssue, blocker, true) :
|
await issueController.updateBlocker(selectedIssue, blocker, true) :
|
||||||
await issueController.updateBlocker(selectedIssue, blocker, false);
|
await issueController.updateBlocker(selectedIssue, blocker, false);
|
||||||
|
|
@ -1437,8 +1449,8 @@
|
||||||
results.querySelectorAll('[data-blocker-result]').forEach(button => {
|
results.querySelectorAll('[data-blocker-result]').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const blocker = issueBlockerCandidates[Number(button.dataset.blockerResult)];
|
const blocker = issueBlockerCandidates[Number(button.dataset.blockerResult)];
|
||||||
if (blocker) mutateIssueBlocker(blocker, false, button).then(() => {
|
if (blocker) mutateIssueBlocker(blocker, false, button).then(outcome => {
|
||||||
if (selectedIssueDetail?.dependencies?.some(candidate =>
|
if (outcome?.queued || selectedIssueDetail?.dependencies?.some(candidate =>
|
||||||
candidate.repository === blocker.repository && candidate.number === blocker.number
|
candidate.repository === blocker.repository && candidate.number === blocker.number
|
||||||
)) {
|
)) {
|
||||||
qs('#issue-blocker-manager').hidden = true;
|
qs('#issue-blocker-manager').hidden = true;
|
||||||
|
|
@ -1474,10 +1486,14 @@
|
||||||
qs('#issue-blocker-search-status').textContent = 'Searching open issues…';
|
qs('#issue-blocker-search-status').textContent = 'Searching open issues…';
|
||||||
issueBlockerSearchTimer = setTimeout(async () => {
|
issueBlockerSearchTimer = setTimeout(async () => {
|
||||||
try {
|
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 existing = selectedIssueDetail?.dependencies || [];
|
||||||
const items = (payload.items || []).filter(result =>
|
const normalizedQuery = query.toLowerCase();
|
||||||
|
const items = (candidates || []).filter(result =>
|
||||||
result.kind === 'issue' && result.state === 'open' &&
|
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) &&
|
!(result.repository === selectedIssue?.repository && result.number === selectedIssue?.number) &&
|
||||||
!existing.some(blocker => blocker.repository === result.repository && blocker.number === result.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/issue-outbox.js"></script>
|
||||||
<script src="static/authored-outbox.js"></script>
|
<script src="static/authored-outbox.js"></script>
|
||||||
<script src="static/offline-issue-close.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/notification-read-outbox.js"></script>
|
||||||
<script src="static/offline-work.js"></script>
|
<script src="static/offline-work.js"></script>
|
||||||
<script src="static/offline-today.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/issue-outbox.js',
|
||||||
BASE + 'static/authored-outbox.js',
|
BASE + 'static/authored-outbox.js',
|
||||||
BASE + 'static/offline-issue-close.js',
|
BASE + 'static/offline-issue-close.js',
|
||||||
|
BASE + 'static/offline-issue-blocker.js',
|
||||||
BASE + 'static/notification-read-outbox.js',
|
BASE + 'static/notification-read-outbox.js',
|
||||||
BASE + 'static/offline-work.js',
|
BASE + 'static/offline-work.js',
|
||||||
BASE + 'static/offline-today.js',
|
BASE + 'static/offline-today.js',
|
||||||
|
|
|
||||||
|
|
@ -1634,6 +1634,19 @@ async def mutate_assigned_issue_dependency(
|
||||||
):
|
):
|
||||||
raise IssueNotAvailableError("assigned issue not found")
|
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:
|
if not remove:
|
||||||
candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}")
|
candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}")
|
||||||
if (
|
if (
|
||||||
|
|
@ -1643,13 +1656,6 @@ async def mutate_assigned_issue_dependency(
|
||||||
or isinstance(candidate.get("pull_request"), dict)
|
or isinstance(candidate.get("pull_request"), dict)
|
||||||
):
|
):
|
||||||
raise IssueDependencyInvalidError("blocker must be an accessible open issue")
|
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)
|
owner, repo = blocker_repository.split("/", 1)
|
||||||
response = await _get_client().request(
|
response = await _get_client().request(
|
||||||
"DELETE" if remove else "POST",
|
"DELETE" if remove else "POST",
|
||||||
|
|
|
||||||
16
src/main.py
16
src/main.py
|
|
@ -511,6 +511,10 @@ class IssueBlockerUpdate(BaseModel):
|
||||||
number: PositiveInt
|
number: PositiveInt
|
||||||
|
|
||||||
|
|
||||||
|
class IssueBlockerDesiredState(IssueBlockerUpdate):
|
||||||
|
present: bool
|
||||||
|
|
||||||
|
|
||||||
class PullReviewComment(BaseModel):
|
class PullReviewComment(BaseModel):
|
||||||
path: str = Field(min_length=1, max_length=1_000)
|
path: str = Field(min_length=1, max_length=1_000)
|
||||||
body: str = Field(min_length=1, max_length=10_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)
|
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")
|
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content")
|
||||||
async def update_assigned_issue_content(
|
async def update_assigned_issue_content(
|
||||||
update: IssueContentUpdate,
|
update: IssueContentUpdate,
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,41 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||||
assert output["remaining"] == []
|
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():
|
def test_authored_outbox_exposes_sending_then_preserves_transient_attempt_details():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
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():
|
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
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
|
@pytest.mark.anyio
|
||||||
async def test_assigned_issue_blocker_route_rejects_invalid_candidate(monkeypatch):
|
async def test_assigned_issue_blocker_route_rejects_invalid_candidate(monkeypatch):
|
||||||
async def mutate(*_args, **_kwargs):
|
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.anyio
|
||||||
@pytest.mark.parametrize("candidate,blocker_repository,blocker_number,existing", [
|
@pytest.mark.parametrize("candidate,blocker_repository,blocker_number,existing", [
|
||||||
({"number": 17, "state": "open"}, "stackchain/dashboard", 17, []),
|
({"number": 17, "state": "open"}, "stackchain/dashboard", 17, []),
|
||||||
({"number": 9, "state": "closed"}, "stackchain/api", 9, []),
|
({"number": 9, "state": "closed"}, "stackchain/api", 9, []),
|
||||||
({"number": 9, "state": "open", "pull_request": {}}, "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(
|
async def test_gitea_dependency_rejects_invalid_candidates(
|
||||||
candidate, blocker_repository, blocker_number, existing
|
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/issue-outbox.js",
|
||||||
"/dashboard/static/authored-outbox.js",
|
"/dashboard/static/authored-outbox.js",
|
||||||
"/dashboard/static/offline-issue-close.js",
|
"/dashboard/static/offline-issue-close.js",
|
||||||
|
"/dashboard/static/offline-issue-blocker.js",
|
||||||
"/dashboard/static/notification-read-outbox.js",
|
"/dashboard/static/notification-read-outbox.js",
|
||||||
"/dashboard/static/offline-work.js",
|
"/dashboard/static/offline-work.js",
|
||||||
"/dashboard/static/offline-today.js",
|
"/dashboard/static/offline-today.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user