Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33d7f4b884 | ||
|
|
05dd6c1e9c | ||
|
|
42f2a077b2 | ||
|
|
0bb76bdc8b | ||
|
|
efafaaddd2 | ||
|
|
a9ffc2d879 | ||
|
|
1fdbba1ca1 | ||
|
|
4b88e1a533 | ||
|
|
7a31ea5cba | ||
|
|
6633ba5244 | ||
|
|
5b954a7a53 | ||
|
|
c30206bfb2 | ||
|
|
cf9a02ae38 | ||
|
|
9f5aa33e95 | ||
|
|
ae3749bab9 | ||
|
|
c1c4ff8e31 | ||
|
|
ff41eb1d57 | ||
|
|
f23839f2b9 | ||
|
|
7cab4e8749 | ||
|
|
8a3316a5c4 | ||
|
|
631eb2e584 | ||
|
|
ed246ce6dd | ||
|
|
fc35b17d24 | ||
|
|
cee3d93864 | ||
|
|
fca99fee86 | ||
|
|
3dd0ba2024 | ||
|
|
871e35c10b | ||
|
|
5c9420ed9c | ||
|
|
8edc83893f | ||
|
|
a226addcc4 | ||
|
|
2402af9e82 | ||
|
|
31f95589cb | ||
|
|
2ac6b1f56b | ||
|
|
6449ead83d | ||
|
|
322f025794 | ||
|
|
9ab22b97f8 | ||
|
|
35747cf9fb | ||
|
|
f094438dab | ||
|
|
04f06042b8 | ||
|
|
a476170fca | ||
|
|
5a579362ff | ||
|
|
d797179844 | ||
|
|
a500915223 | ||
|
|
59a2e4f616 | ||
|
|
e125019f57 | ||
|
|
6ba823abd2 | ||
|
|
f44fa6e579 | ||
|
|
87ff83bd2d | ||
|
|
7dd396154d | ||
|
|
af5fe9b69d | ||
|
|
807364c63b | ||
|
|
6315323a48 | ||
|
|
6a9271cabb | ||
|
|
a1c56bd644 | ||
|
|
ebc3b9ee4d | ||
|
|
19772b5797 | ||
|
|
6314efb82b | ||
|
|
5cda95eeb2 | ||
|
|
0c71dea00c | ||
|
|
53539e0cdf | ||
|
|
9a24aff537 | ||
|
|
43769460fc | ||
|
|
2e62eb09f5 | ||
|
|
1ef3e291c2 | ||
|
|
3694f2501b | ||
|
|
c3d58d7111 | ||
|
|
da45cc36a3 |
No files matched your search
+22
-1
@@ -36,9 +36,30 @@ jobs:
|
||||
name: release-bundle
|
||||
path: dist/
|
||||
|
||||
browser-journey:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-release
|
||||
env:
|
||||
STACKCHAIN_RUN_RELEASE_E2E: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.11" }
|
||||
- name: Download assembled release bundle
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist
|
||||
- name: Install browser test dependencies
|
||||
run: |
|
||||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise mobile offline filing from the release artifact
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, build-release]
|
||||
needs: [lint, build-release, browser-journey]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -24,19 +24,25 @@ For online delivery, the screenshot uploads before the comment is posted—to th
|
||||
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
|
||||
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
|
||||
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
|
||||
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
|
||||
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
|
||||
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
|
||||
preview when the operator later chooses a repository. The source Draft remains available until its
|
||||
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
|
||||
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
|
||||
either stage. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
|
||||
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
|
||||
to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before
|
||||
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
|
||||
bundle when the operator later chooses a repository. A scrollable thumbnail tray lets the operator
|
||||
review every restored image before filing; **Move earlier**, **Move later**, and **Remove selected**
|
||||
change the durable evidence order without changing the issue title or note. The active screenshot's
|
||||
optional **Evidence note** stays paired with that image through reorder, Draft restore, offline delivery,
|
||||
and retry, then appears as a Markdown-safe caption immediately before its uploaded image. The selected screenshot can be cropped, privacy-redacted, highlighted, and marked with touch-drawn arrows before a flattened derivative replaces it; undo, reset, and cancel keep editing reversible without changing its note or bundle position. The source Draft remains
|
||||
available until its evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob.
|
||||
Repository-aware durable admission likewise stores the evidence bundle with its account-bound outbox
|
||||
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
|
||||
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
|
||||
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
|
||||
delivery time.
|
||||
Delivery creates the issue exactly once, then uploads and comments with
|
||||
the image; after a partial failure, retry resumes with the confirmed issue instead of
|
||||
creating a duplicate.
|
||||
Delivery creates the issue exactly once, then uploads each image under a checkpointed per-image
|
||||
identity and posts one ordered Markdown evidence comment. After a partial failure, retry resumes with the confirmed issue and from
|
||||
the first unconfirmed image instead of duplicating the issue or earlier uploads. The installed PWA
|
||||
Share Target accepts the same bounded multi-image bundle through sign-in continuation.
|
||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||
issue-comment API. In issue, pull-request, and unread-update conversations, typing
|
||||
at least two characters after `@` offers repository-scoped teammate suggestions;
|
||||
@@ -56,9 +62,14 @@ repository milestone and due date; the dashboard validates both and sends them w
|
||||
self-assignment in the single create request, so planned work appears in its release
|
||||
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
|
||||
account-bound title/description captures without selecting a repository or entering the
|
||||
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
|
||||
the same Gitea login, **Choose repository** restores the capture to the normal planning
|
||||
and durable delivery flow. A different or unconfirmed account can only copy or discard
|
||||
mutation outbox. Drafts marks them **Needs filing** and saves locally first. After a fresh
|
||||
reconnect confirms the same Gitea login, a bounded, revisioned collection synchronizes the title,
|
||||
description, blockers, and ordered screenshot evidence; another signed-in device can then use
|
||||
**Choose repository** to continue the normal planning and durable delivery flow. Draft cards report
|
||||
whether they are synced or still pending locally. Concurrent changes surface a conflict, and
|
||||
successful discard or outbox admission propagates deletion so a stale device cannot resurrect the
|
||||
draft. Synchronization is limited to 20 drafts and 12 MiB of decoded evidence per account; failure
|
||||
never blocks local capture. A different or unconfirmed account can only copy or discard
|
||||
the private content. Issue capture and authored mobile actions (issue
|
||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||
@@ -68,6 +79,12 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p
|
||||
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
||||
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
||||
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
||||
Named mobile Search views preserve the query, type, status, and optional repository scope. They are
|
||||
bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another
|
||||
device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently
|
||||
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
|
||||
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
||||
`.stackchain-state/saved-searches.sqlite3` path.
|
||||
Search previews also
|
||||
let operators assign an eligible issue and add it to Today without starting or replacing active work.
|
||||
The queue action keeps the Search query, filters, results, and scroll position available for continued
|
||||
|
||||
@@ -91,11 +91,18 @@ function createIssueSyncStore({
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const preservedBundle = current.operationId === replacement.operationId &&
|
||||
current.attachments?.every(value => value?.data || value?.blob) &&
|
||||
replacement.attachments?.every(value => value?.stored && !value.data && !value.blob)
|
||||
? current.attachments : null;
|
||||
if (current.operationId === replacement.operationId &&
|
||||
(current.attachment?.data || current.attachment?.blob) &&
|
||||
replacement.attachment?.stored &&
|
||||
!replacement.attachment.data && !replacement.attachment.blob) {
|
||||
replacement = { ...replacement, attachment: current.attachment };
|
||||
!replacement.attachment.data && !replacement.attachment.blob || preservedBundle) {
|
||||
replacement = {
|
||||
...replacement,
|
||||
...(preservedBundle ? {attachments:preservedBundle} : {attachment:current.attachment}),
|
||||
};
|
||||
incoming.set(current.id, replacement);
|
||||
}
|
||||
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
@@ -353,6 +360,9 @@ function createBackgroundIssueSync({
|
||||
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
|
||||
}
|
||||
if (typeof delivered.url === 'string' && /^https?:\/\//.test(delivered.url)) {
|
||||
return { id: item.id, status, kind: 'issue', url: delivered.url };
|
||||
}
|
||||
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
|
||||
}
|
||||
|
||||
@@ -439,6 +449,8 @@ function createBackgroundIssueSync({
|
||||
title: item.title,
|
||||
body: item.body,
|
||||
label_ids: item.labelIds,
|
||||
...(item.unassigned ? { unassigned: true } : {}),
|
||||
...(item.assignee ? { assignee: item.assignee } : {}),
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
@@ -466,6 +478,13 @@ function createBackgroundIssueSync({
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
function evidenceMarkdown(attachment, markdown, index) {
|
||||
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
if (!note) return markdown;
|
||||
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
|
||||
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
|
||||
}
|
||||
|
||||
async function deliverReplyRead(item) {
|
||||
let current = item;
|
||||
if (!current.replyConfirmed) {
|
||||
@@ -525,8 +544,31 @@ function createBackgroundIssueSync({
|
||||
deliveredIssue = await requestStage(item, request.url, request.options);
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
|
||||
}
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const blockers = Array.isArray(item.blockers) ? item.blockers : [];
|
||||
let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length);
|
||||
for (let index = deliveredBlockers; index < blockers.length; index += 1) {
|
||||
const blocker = blockers[index];
|
||||
await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/blockers',
|
||||
{
|
||||
method:'PATCH',
|
||||
headers:{
|
||||
Accept:'application/json', 'Content-Type':'application/json',
|
||||
'Idempotency-Key':stageOperationId(item.operationId, 'blocker-' + index),
|
||||
},
|
||||
body:JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}),
|
||||
},
|
||||
);
|
||||
deliveredBlockers = index + 1;
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue, deliveredBlockers }));
|
||||
}
|
||||
const attachments = (Array.isArray(item.attachments) ? item.attachments : [item.attachment]).filter(Boolean);
|
||||
if (!attachments.length) return deliveredIssue;
|
||||
const attachmentMarkdowns = Array.isArray(item.attachmentMarkdowns)
|
||||
? item.attachmentMarkdowns.slice(0, attachments.length)
|
||||
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
|
||||
for (let index = attachmentMarkdowns.length; index < attachments.length; index += 1) {
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
|
||||
@@ -534,19 +576,27 @@ function createBackgroundIssueSync({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
'Idempotency-Key': stageOperationId(
|
||||
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
|
||||
),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
body: attachmentMultipart(attachments[index]),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const markdown = String(uploaded?.markdown || '');
|
||||
if (!markdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
|
||||
attachmentMarkdowns.push(markdown);
|
||||
await checkpointClaim(item, current => ({
|
||||
...current, deliveredIssue, attachmentMarkdowns:attachmentMarkdowns.slice(),
|
||||
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||
}));
|
||||
}
|
||||
const attachmentMarkdown = attachmentMarkdowns.map((markdown, index) =>
|
||||
evidenceMarkdown(attachments[index], markdown, index)).join('\n\n');
|
||||
await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
|
||||
@@ -638,7 +688,9 @@ function createBackgroundIssueSync({
|
||||
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
|
||||
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
||||
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
|
||||
await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||
const error = new Error('Issue closure was not confirmed.');
|
||||
error.status = 422;
|
||||
|
||||
@@ -8,6 +8,7 @@ function createBatchFindWork({
|
||||
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
||||
owner = () => '',
|
||||
journalName = 'find-work-batch',
|
||||
queueFailureReason = 'assigned but Today sync is unavailable',
|
||||
autoMount = true,
|
||||
}) {
|
||||
let request = null;
|
||||
@@ -58,7 +59,7 @@ function createBatchFindWork({
|
||||
entry.state = 'assigned';
|
||||
writeJournal(journal);
|
||||
}
|
||||
const queueResult = await queue(confirmed);
|
||||
const queueResult = await queue(confirmed, journal.context);
|
||||
if (queueResult === 'queued' || queueResult === 'exists') {
|
||||
queued.push(key(item));
|
||||
entry.state = 'queued';
|
||||
@@ -67,7 +68,7 @@ function createBatchFindWork({
|
||||
}
|
||||
writeJournal(journal);
|
||||
} else {
|
||||
failed.push({ key: key(item), reason: 'assigned but Today sync is unavailable', assigned: true });
|
||||
failed.push({ key: key(item), reason: queueFailureReason, assigned: true });
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
||||
@@ -81,7 +82,7 @@ function createBatchFindWork({
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function run(items, estimates = {}) {
|
||||
function run(items, estimates = {}, context = null) {
|
||||
if (request) return request;
|
||||
const selected = Array.isArray(items) ? items.slice() : [];
|
||||
const available = Math.max(0, Number(capacity()) || 0);
|
||||
@@ -112,7 +113,7 @@ function createBatchFindWork({
|
||||
}
|
||||
}
|
||||
const journal = {
|
||||
owner: String(owner() || ''), available,
|
||||
owner: String(owner() || ''), available, context,
|
||||
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
|
||||
};
|
||||
writeJournal(journal);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
function createConversationActionHydrator({ load, activate }) {
|
||||
let actions = null;
|
||||
let pending = null;
|
||||
const wiredRoots = new WeakSet();
|
||||
|
||||
function ensure() {
|
||||
if (actions) return Promise.resolve(actions);
|
||||
if (!pending) {
|
||||
pending = load().then(() => {
|
||||
actions = activate();
|
||||
return actions;
|
||||
}).catch(error => {
|
||||
pending = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
function show({ root, state, paint, wire, retry }) {
|
||||
if (actions) {
|
||||
retry.hidden = true;
|
||||
if (!wiredRoots.has(root)) {
|
||||
wire(actions);
|
||||
wiredRoots.add(root);
|
||||
}
|
||||
paint(state, actions);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
paint(state, null);
|
||||
retry.hidden = true;
|
||||
return ensure().then(controller => {
|
||||
if (!wiredRoots.has(root)) {
|
||||
wire(controller);
|
||||
wiredRoots.add(root);
|
||||
}
|
||||
paint(state, controller);
|
||||
return true;
|
||||
}).catch(() => {
|
||||
retry.hidden = false;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return { show, ready: () => Boolean(actions) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationActionHydrator;
|
||||
@@ -5,7 +5,40 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
|
||||
return todayWork.read().length < todayWork.limit;
|
||||
}
|
||||
|
||||
function complete(issue) {
|
||||
function capacity(value) {
|
||||
if (String(value ?? '').trim() === '') return { valid: false, reason: 'required' };
|
||||
const minutes = Number(value);
|
||||
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) {
|
||||
return { valid: false, reason: 'range' };
|
||||
}
|
||||
const plan = todayWork.planning();
|
||||
const capacityMinutes = plan.capacity_minutes;
|
||||
if (!Number.isInteger(capacityMinutes)) {
|
||||
return { valid: true, minutes, capacityMinutes: null, plannedMinutes: null,
|
||||
remainingMinutes: null, projectedMinutes: null, fits: true };
|
||||
}
|
||||
const plannedMinutes = todayWork.read().reduce(
|
||||
(total, identity) => total + (Number.isInteger(plan.estimates[identity]) ? plan.estimates[identity] : 0), 0
|
||||
);
|
||||
const remainingMinutes = capacityMinutes - plannedMinutes;
|
||||
const projectedMinutes = remainingMinutes - minutes;
|
||||
return { valid: true, minutes, capacityMinutes, plannedMinutes, remainingMinutes,
|
||||
projectedMinutes, fits: projectedMinutes >= 0 };
|
||||
}
|
||||
|
||||
function capacityMessage(result) {
|
||||
if (!result.valid) return result.reason === 'required'
|
||||
? 'Required only for Create & start.'
|
||||
: 'Use a whole number from 5 to 1440 minutes.';
|
||||
if (result.capacityMinutes === null) {
|
||||
return result.minutes + ' min · Set a Today budget to see remaining time.';
|
||||
}
|
||||
return result.fits
|
||||
? result.minutes + ' min · ' + result.projectedMinutes + ' min will remain in Today.'
|
||||
: result.minutes + ' min · ' + Math.abs(result.projectedMinutes) + ' min over Today capacity.';
|
||||
}
|
||||
|
||||
function complete(issue, estimateMinutes) {
|
||||
const identity = todayWork.identity(issue);
|
||||
if (!identity || completed.has(identity)) return 'exists';
|
||||
const added = todayWork.add(issue);
|
||||
@@ -17,10 +50,25 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
|
||||
announce('Created, but Today could not be saved on this device.');
|
||||
return 'unavailable';
|
||||
}
|
||||
let estimatedPlan = null;
|
||||
if (Number.isInteger(estimateMinutes)) {
|
||||
estimatedPlan = todayWork.planning();
|
||||
estimatedPlan.estimates[identity] = estimateMinutes;
|
||||
if (!todayWork.replacePlanning(estimatedPlan)) {
|
||||
announce('Created and added to Today, but its estimate could not be saved on this device.');
|
||||
return 'estimate-unavailable';
|
||||
}
|
||||
}
|
||||
if (!todaySync.enqueue('add', identity)) {
|
||||
announce('Created and saved to Today on this device, but account sync is unavailable.');
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
if (estimatedPlan) {
|
||||
if (!todaySync.enqueueConfiguration(estimatedPlan.capacity_minutes, estimatedPlan.estimates)) {
|
||||
announce('Created and estimated on this device, but Today plan sync is unavailable.');
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
}
|
||||
completed.add(identity);
|
||||
refresh();
|
||||
todaySync.flush();
|
||||
@@ -30,7 +78,7 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
|
||||
return 'started';
|
||||
}
|
||||
|
||||
return { available, complete };
|
||||
return { available, capacity, capacityMessage, complete };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart;
|
||||
@@ -19,22 +19,165 @@ function normalizeSharedContent(value = {}) {
|
||||
return { title, body };
|
||||
}
|
||||
|
||||
function buildRelatedDraft(value = {}, template = null) {
|
||||
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(value.repository || ''))
|
||||
? String(value.repository) : '';
|
||||
const labelIds = Array.from(new Set((Array.isArray(value.labelIds) ? value.labelIds : [])
|
||||
.filter(id => Number.isInteger(id) && id > 0))).slice(0, 20);
|
||||
const draft = {repository, title:'', body:'', labelIds};
|
||||
const milestoneId = Number(value.milestoneId);
|
||||
if (Number.isInteger(milestoneId) && milestoneId > 0) draft.milestoneId = milestoneId;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(String(value.dueDate || ''))) draft.dueDate = String(value.dueDate);
|
||||
if (value.unassigned === true) draft.unassigned = true;
|
||||
else if (/^[A-Za-z0-9_.-]+$/.test(String(value.assignee || ''))) {
|
||||
draft.assignee = String(value.assignee);
|
||||
draft.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
const templateId = String(value.templateId || '').slice(0, 80);
|
||||
if (templateId && template && String(template.id || '') === templateId) {
|
||||
draft.templateId = templateId;
|
||||
draft.templateName = String(value.templateName || template.name || 'Issue template').trim().slice(0, 80);
|
||||
draft.capturedBody = '';
|
||||
draft.body = String(template.body || '').trim().slice(0, 9000);
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
|
||||
const NO_OWNER = '__unassigned__';
|
||||
const select = documentRef.querySelector('#create-issue-assignee');
|
||||
const status = documentRef.querySelector('#create-issue-assignee-status');
|
||||
const getRepository = () => documentRef.querySelector('#create-issue-repository').value;
|
||||
const loadOwners = repository => issueCapture.loadOwners(repository);
|
||||
let ownerRequest = 0;
|
||||
function reset(repository, selected = {}) {
|
||||
select.replaceChildren();
|
||||
const me = documentRef.createElement('option');
|
||||
me.value = '';
|
||||
me.textContent = 'Me';
|
||||
select.appendChild(me);
|
||||
const noOwner = documentRef.createElement('option');
|
||||
noOwner.value = NO_OWNER;
|
||||
noOwner.textContent = 'No owner';
|
||||
select.appendChild(noOwner);
|
||||
if (selected.unassigned === true) select.value = NO_OWNER;
|
||||
if (selected.assignee) {
|
||||
const option = documentRef.createElement('option');
|
||||
option.value = selected.assignee;
|
||||
option.textContent = (selected.assigneeName || selected.assignee) + ' (@' + selected.assignee + ')';
|
||||
option.dataset.name = selected.assigneeName || selected.assignee;
|
||||
select.appendChild(option);
|
||||
select.value = selected.assignee;
|
||||
}
|
||||
select.dataset.repository = '';
|
||||
status.textContent = repository ? 'Open the owner picker to load eligible teammates.' : 'Choose a repository first.';
|
||||
}
|
||||
|
||||
async function load(repository) {
|
||||
if (!repository || select.dataset.repository === repository) return;
|
||||
const request = ++ownerRequest;
|
||||
const selected = select.value;
|
||||
const selectedName = select.selectedOptions?.[0]?.dataset.name || '';
|
||||
status.textContent = 'Loading eligible teammates…';
|
||||
try {
|
||||
const owners = await loadOwners(repository);
|
||||
if (request !== ownerRequest || getRepository() !== repository) return;
|
||||
const selectedStillEligible = owners.some(owner => owner?.login === selected);
|
||||
reset(repository, selected === NO_OWNER ? {unassigned:true} :
|
||||
(selectedStillEligible ? {assignee:selected, assigneeName:selectedName} : {}));
|
||||
owners.forEach(owner => {
|
||||
if (!owner?.login || owner.login === selected) return;
|
||||
const option = documentRef.createElement('option');
|
||||
option.value = owner.login;
|
||||
option.textContent = (owner.name || owner.login) + ' (@' + owner.login + ')';
|
||||
option.dataset.name = owner.name || owner.login;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.dataset.repository = repository;
|
||||
status.textContent = owners.length ? 'Choose yourself, no owner, or an eligible teammate.' :
|
||||
'Choose yourself or no owner; no eligible teammates are available.';
|
||||
} catch (_error) {
|
||||
if (request !== ownerRequest || getRepository() !== repository) return;
|
||||
status.textContent = 'Teammates could not be loaded. The issue will stay assigned to you.';
|
||||
}
|
||||
}
|
||||
function updateActions(hasRepository, hasBlockers, canStart) {
|
||||
const submit = documentRef.querySelector('#submit-new-issue');
|
||||
const start = documentRef.querySelector('#create-and-start-issue');
|
||||
const hasNoOwner = select.value === NO_OWNER;
|
||||
const hasTeammateOwner = Boolean(select.value) && !hasNoOwner;
|
||||
submit.disabled = !hasRepository;
|
||||
submit.textContent = hasNoOwner ? 'Create unassigned' :
|
||||
(hasTeammateOwner ? 'Create & assign' : 'Create & assign to me');
|
||||
start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || hasNoOwner || !canStart;
|
||||
start.title = hasBlockers ? 'Blocked work cannot start until its blockers are complete.' :
|
||||
(hasNoOwner ? 'No owner work cannot be added to your Today queue.' :
|
||||
(hasTeammateOwner ? 'Work assigned to a teammate cannot be added to your Today queue.' : ''));
|
||||
}
|
||||
function fields() {
|
||||
return {
|
||||
assignee: select.value === NO_OWNER ? '' : select.value,
|
||||
assigneeName: select.value === NO_OWNER ? '' : (select.selectedOptions?.[0]?.dataset.name || ''),
|
||||
unassigned: select.value === NO_OWNER,
|
||||
};
|
||||
}
|
||||
function draft(labelIds, blockers, trim = false) {
|
||||
const value = id => documentRef.querySelector(id).value;
|
||||
const clean = input => trim ? input.trim() : input;
|
||||
return {
|
||||
repository:value('#create-issue-repository'),
|
||||
title:clean(value('#create-issue-title')), body:clean(value('#create-issue-body')),
|
||||
labelIds, milestoneId:Number(value('#create-issue-milestone')) || null,
|
||||
dueDate:value('#create-issue-due-date'), ...fields(), blockers,
|
||||
};
|
||||
}
|
||||
select.addEventListener('focus', () => {
|
||||
const repository = getRepository();
|
||||
if (repository) load(repository);
|
||||
});
|
||||
select.addEventListener('change', onChange);
|
||||
return { reset, load, updateActions, fields, draft };
|
||||
}
|
||||
|
||||
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
|
||||
let pending = null;
|
||||
let issueTemplates = [];
|
||||
let activeTemplate = null;
|
||||
let templateRequest = 0;
|
||||
let duplicateRequest = 0;
|
||||
let repositorySearchRequest = 0;
|
||||
let filingMetadataRequest = 0;
|
||||
let blockerSearchRequest = 0;
|
||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||
let acknowledgedDuplicateKey = '';
|
||||
const repositoryPageRequests = new Map();
|
||||
const safeLabelIds = value => Array.from(new Set(
|
||||
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||
)).slice(0, 20);
|
||||
const safeBlockers = value => {
|
||||
const seen = new Set();
|
||||
return (Array.isArray(value) ? value : []).reduce((items, blocker) => {
|
||||
const repository = String(blocker?.repository || '').trim();
|
||||
const number = Number(blocker?.number);
|
||||
const key = repository + '#' + number;
|
||||
if (items.length >= 5 || seen.has(key) ||
|
||||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
||||
!Number.isInteger(number) || number < 1) return items;
|
||||
seen.add(key);
|
||||
items.push({repository, number,
|
||||
title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)});
|
||||
return items;
|
||||
}, []);
|
||||
};
|
||||
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) : '';
|
||||
const safeAssignee = value => /^[A-Za-z0-9_.-]+$/.test(String(value || '')) ? String(value) : '';
|
||||
const safeEstimate = value => Number.isInteger(Number(value)) && Number(value) >= 5 && Number(value) <= 1440 ?
|
||||
Number(value) : null;
|
||||
|
||||
function loadStored() {
|
||||
try {
|
||||
@@ -47,10 +190,28 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
labelIds: safeLabelIds(parsed.labelIds),
|
||||
operationId: String(parsed.operationId || '').slice(0, 128),
|
||||
};
|
||||
if (parsed.unassigned === true) draft.unassigned = true;
|
||||
if (typeof parsed.templateName === 'string' && parsed.templateName.trim()) {
|
||||
draft.templateName = parsed.templateName.trim().slice(0, 80);
|
||||
draft.templateId = String(parsed.templateId || '').slice(0, 80);
|
||||
draft.capturedBody = String(parsed.capturedBody || '').slice(0, 10000);
|
||||
}
|
||||
const assignee = safeAssignee(parsed.assignee);
|
||||
if (assignee) {
|
||||
draft.assignee = assignee;
|
||||
draft.assigneeName = String(parsed.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
const milestoneId = safeMilestoneId(parsed.milestoneId);
|
||||
const dueDate = safeDueDate(parsed.dueDate);
|
||||
if (milestoneId !== null) draft.milestoneId = milestoneId;
|
||||
if (dueDate) draft.dueDate = dueDate;
|
||||
const estimateMinutes = safeEstimate(parsed.estimateMinutes);
|
||||
if (estimateMinutes !== null) draft.estimateMinutes = estimateMinutes;
|
||||
if (['create', 'create-and-start'].includes(parsed.completionIntent)) {
|
||||
draft.completionIntent = parsed.completionIntent;
|
||||
}
|
||||
const blockers = safeBlockers(parsed.blockers);
|
||||
if (blockers.length) draft.blockers = blockers;
|
||||
return draft;
|
||||
} catch (_error) {
|
||||
return {...emptyDraft(), operationId: ''};
|
||||
@@ -70,13 +231,33 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: safeLabelIds(draft?.labelIds),
|
||||
};
|
||||
if (draft?.unassigned === true) safe.unassigned = true;
|
||||
if (typeof draft?.templateName === 'string' && draft.templateName.trim()) {
|
||||
safe.templateName = draft.templateName.trim().slice(0, 80);
|
||||
safe.templateId = String(draft.templateId || '').slice(0, 80);
|
||||
safe.capturedBody = String(draft.capturedBody || '').slice(0, 10000);
|
||||
}
|
||||
const assignee = safe.unassigned ? '' : safeAssignee(draft?.assignee);
|
||||
if (assignee) {
|
||||
safe.assignee = assignee;
|
||||
safe.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
const milestoneId = safeMilestoneId(draft?.milestoneId);
|
||||
const dueDate = safeDueDate(draft?.dueDate);
|
||||
if (milestoneId !== null) safe.milestoneId = milestoneId;
|
||||
if (dueDate) safe.dueDate = dueDate;
|
||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
|
||||
const estimateMinutes = safeEstimate(draft?.estimateMinutes);
|
||||
if (estimateMinutes !== null) safe.estimateMinutes = estimateMinutes;
|
||||
if (['create', 'create-and-start'].includes(draft?.completionIntent)) {
|
||||
safe.completionIntent = draft.completionIntent;
|
||||
}
|
||||
const blockers = safeBlockers(draft?.blockers);
|
||||
if (blockers.length) safe.blockers = blockers;
|
||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName', 'unassigned',
|
||||
'templateName', 'templateId', 'capturedBody', 'estimateMinutes', 'completionIntent']
|
||||
.every(key => (previous[key] || '') === (safe[key] || '')) &&
|
||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
|
||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) &&
|
||||
JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []);
|
||||
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
|
||||
return safe;
|
||||
}
|
||||
@@ -182,6 +363,266 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
);
|
||||
}
|
||||
|
||||
function loadOwners(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return fetchJson('api/v1/repos/' + encoded + '/issue-assignees').then(owners =>
|
||||
Array.isArray(owners) ? owners : []
|
||||
);
|
||||
}
|
||||
|
||||
function loadTemplates(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return fetchJson('api/v1/repos/' + encoded + '/issue-templates').then(templates =>
|
||||
Array.isArray(templates) ? templates.slice(0, 20) : []
|
||||
);
|
||||
}
|
||||
|
||||
async function loadFilingMetadata(repository) {
|
||||
const selectedRepository = String(repository || '');
|
||||
const encoded = selectedRepository.split('/').map(encodeURIComponent).join('/');
|
||||
const request = ++filingMetadataRequest;
|
||||
const payload = await fetchJson(
|
||||
'api/v1/repos/' + encoded + '/issue-filing-metadata'
|
||||
);
|
||||
if (request !== filingMetadataRequest) {
|
||||
return {status:'stale', repository:selectedRepository};
|
||||
}
|
||||
const section = name => {
|
||||
const value = payload?.[name];
|
||||
const available = value?.available === true;
|
||||
return {
|
||||
available,
|
||||
items:available && Array.isArray(value?.items) ? value.items : [],
|
||||
...(!available && typeof value?.error === 'string' ? {error:value.error} : {}),
|
||||
};
|
||||
};
|
||||
return {
|
||||
status:'ready', repository:selectedRepository,
|
||||
labels:section('labels'), milestones:section('milestones'),
|
||||
templates:section('templates'),
|
||||
};
|
||||
}
|
||||
|
||||
function applyTemplate(draft, template, availableLabels = []) {
|
||||
const source = {...(draft || {})};
|
||||
const capturedBody = source.templateName ? String(source.capturedBody || '') : String(source.body || '');
|
||||
if (!template) {
|
||||
delete source.templateName;
|
||||
delete source.templateId;
|
||||
delete source.capturedBody;
|
||||
return {...source, body: capturedBody};
|
||||
}
|
||||
const scaffold = String(template.body || '').trim().slice(0, 9000);
|
||||
const body = [capturedBody.trim(), scaffold].filter(Boolean).join('\n\n---\n\n').slice(0, 10000);
|
||||
const validByName = new Map((Array.isArray(availableLabels) ? availableLabels : [])
|
||||
.filter(label => Number.isInteger(label?.id) && typeof label?.name === 'string')
|
||||
.map(label => [label.name.toLowerCase(), label.id]));
|
||||
const templateLabelIds = (Array.isArray(template.labels) ? template.labels : [])
|
||||
.map(name => validByName.get(String(name).toLowerCase())).filter(Boolean);
|
||||
return {
|
||||
...source,
|
||||
title: source.title || String(template.title || '').trim().slice(0, 255),
|
||||
body,
|
||||
labelIds: safeLabelIds([...(source.labelIds || []), ...templateLabelIds]),
|
||||
templateId: String(template.id || '').slice(0, 80),
|
||||
templateName: String(template.name || 'Issue template').trim().slice(0, 80),
|
||||
capturedBody,
|
||||
};
|
||||
}
|
||||
|
||||
function setTemplateState(draft) {
|
||||
activeTemplate = draft?.templateName ? {
|
||||
templateName:draft.templateName, templateId:draft.templateId,
|
||||
capturedBody:draft.capturedBody || '',
|
||||
} : null;
|
||||
}
|
||||
|
||||
function templateFields(draft) {
|
||||
return activeTemplate ? {...draft, ...activeTemplate} : draft;
|
||||
}
|
||||
|
||||
function restoreTemplate(draft, availableLabels) {
|
||||
const restored = applyTemplate(templateFields(draft), null, availableLabels);
|
||||
activeTemplate = null;
|
||||
return restored;
|
||||
}
|
||||
|
||||
async function loadTemplateOptions(repository, elements, selectedId = '') {
|
||||
const request = ++templateRequest;
|
||||
issueTemplates = [];
|
||||
elements.select.innerHTML = '<option value="">Blank issue</option>';
|
||||
elements.field.hidden = true;
|
||||
if (!repository) return;
|
||||
elements.status.textContent = 'Loading issue types…';
|
||||
try {
|
||||
const templates = await loadTemplates(repository);
|
||||
if (request !== templateRequest || elements.getRepository() !== repository) return;
|
||||
issueTemplates = templates;
|
||||
templates.forEach(template => {
|
||||
const option = elements.document.createElement('option');
|
||||
option.value = template.id;
|
||||
option.textContent = template.name;
|
||||
elements.select.appendChild(option);
|
||||
});
|
||||
elements.field.hidden = templates.length === 0;
|
||||
elements.select.value = templates.some(template => template.id === selectedId) ? selectedId : '';
|
||||
elements.status.textContent = templates.length ?
|
||||
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
|
||||
} catch (_error) {
|
||||
if (request !== templateRequest || elements.getRepository() !== repository) return;
|
||||
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
|
||||
}
|
||||
}
|
||||
|
||||
function selectTemplate(identifier, draft, availableLabels) {
|
||||
const template = issueTemplates.find(item => item.id === identifier) || null;
|
||||
const applied = applyTemplate(templateFields(draft), template, availableLabels);
|
||||
activeTemplate = template ? {
|
||||
templateId:applied.templateId, templateName:applied.templateName,
|
||||
capturedBody:applied.capturedBody,
|
||||
} : null;
|
||||
return {draft: applied, template};
|
||||
}
|
||||
|
||||
function relatedDraft(draft) {
|
||||
const template = issueTemplates.find(item => String(item?.id || '') === String(draft?.templateId || '')) || null;
|
||||
return buildRelatedDraft(draft, template);
|
||||
}
|
||||
|
||||
function bindTemplatePicker(elements, callbacks) {
|
||||
let labels = [];
|
||||
elements.select.addEventListener('change', event => {
|
||||
const {draft, template} = selectTemplate(event.target.value, callbacks.getDraft(), labels);
|
||||
callbacks.setDraft(draft);
|
||||
const selected = new Set(draft.labelIds || []);
|
||||
elements.labelInputs().forEach(input => { input.checked = selected.has(Number(input.value)); });
|
||||
elements.status.textContent = template ?
|
||||
template.name + (template.about ? ' — ' + template.about : '') : 'Blank issue selected.';
|
||||
callbacks.changed();
|
||||
});
|
||||
return {
|
||||
fields:templateFields,
|
||||
reset:setTemplateState,
|
||||
setLabels(value) { labels = Array.isArray(value) ? value : []; },
|
||||
load(repository, selectedId) {
|
||||
return loadTemplateOptions(repository, elements, selectedId);
|
||||
},
|
||||
render(repository, section, selectedId = '') {
|
||||
++templateRequest;
|
||||
issueTemplates = [];
|
||||
elements.select.innerHTML = '<option value="">Blank issue</option>';
|
||||
elements.field.hidden = true;
|
||||
if (!repository || elements.getRepository() !== repository) return;
|
||||
if (section?.available !== true) {
|
||||
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
|
||||
return;
|
||||
}
|
||||
issueTemplates = Array.isArray(section.items) ? section.items.slice(0, 20) : [];
|
||||
issueTemplates.forEach(template => {
|
||||
const option = elements.document.createElement('option');
|
||||
option.value = template.id;
|
||||
option.textContent = template.name;
|
||||
elements.select.appendChild(option);
|
||||
});
|
||||
elements.field.hidden = issueTemplates.length === 0;
|
||||
elements.select.value = issueTemplates.some(template => template.id === selectedId) ? selectedId : '';
|
||||
elements.status.textContent = issueTemplates.length ?
|
||||
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
|
||||
},
|
||||
changeRepository(draft) {
|
||||
const restored = restoreTemplate(draft, labels);
|
||||
callbacks.setDraft(restored);
|
||||
return restored;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bindFilingMetadata(elements, templatePicker) {
|
||||
function renderLabels(repository, section, selectedIds = []) {
|
||||
elements.labelList.replaceChildren();
|
||||
if (!repository) {
|
||||
elements.labelStatus.textContent = 'Choose a repository to load labels.';
|
||||
return;
|
||||
}
|
||||
if (section?.available !== true) {
|
||||
templatePicker.setLabels([]);
|
||||
elements.labelStatus.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
|
||||
return;
|
||||
}
|
||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||
const labels = (Array.isArray(section.items) ? section.items : []).slice().sort((left, right) =>
|
||||
Number(priorities.has(String(right?.name || '').toLowerCase())) -
|
||||
Number(priorities.has(String(left?.name || '').toLowerCase()))
|
||||
);
|
||||
templatePicker.setLabels(labels);
|
||||
const selected = new Set(selectedIds.map(Number));
|
||||
labels.forEach(label => {
|
||||
const option = elements.document.createElement('label');
|
||||
option.className = 'create-issue-label-option';
|
||||
const input = elements.document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.name = 'create-issue-label';
|
||||
input.value = String(Number(label.id));
|
||||
input.checked = selected.has(Number(label.id));
|
||||
const name = elements.document.createElement('span');
|
||||
name.textContent = label.name;
|
||||
option.append(input, name);
|
||||
elements.labelList.appendChild(option);
|
||||
});
|
||||
elements.labelStatus.textContent = labels.length ?
|
||||
'Select labels to triage this issue.' : 'This repository has no labels.';
|
||||
}
|
||||
|
||||
function renderMilestones(repository, section, selectedId = null) {
|
||||
elements.milestoneSelect.replaceChildren();
|
||||
const blank = elements.document.createElement('option');
|
||||
blank.value = '';
|
||||
blank.textContent = 'No milestone';
|
||||
elements.milestoneSelect.appendChild(blank);
|
||||
if (!repository) {
|
||||
elements.milestoneStatus.textContent = 'Choose a repository to load milestones.';
|
||||
return;
|
||||
}
|
||||
if (section?.available !== true) {
|
||||
elements.milestoneStatus.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
|
||||
return;
|
||||
}
|
||||
const milestones = Array.isArray(section.items) ? section.items : [];
|
||||
milestones.forEach(milestone => {
|
||||
const option = elements.document.createElement('option');
|
||||
option.value = String(Number(milestone.id));
|
||||
option.textContent = milestone.title;
|
||||
elements.milestoneSelect.appendChild(option);
|
||||
});
|
||||
if (selectedId) elements.milestoneSelect.value = String(selectedId);
|
||||
elements.milestoneStatus.textContent = milestones.length ?
|
||||
'Choose the release lane for this issue.' : 'This repository has no open milestones.';
|
||||
}
|
||||
|
||||
async function load(repository, selected = {}) {
|
||||
renderLabels(repository, null, selected.labelIds || []);
|
||||
renderMilestones(repository, null, selected.milestoneId);
|
||||
templatePicker.render(repository, null, selected.templateId);
|
||||
if (!repository) return;
|
||||
elements.labelStatus.textContent = 'Loading labels…';
|
||||
elements.milestoneStatus.textContent = 'Loading milestones…';
|
||||
elements.templateStatus.textContent = 'Loading issue types…';
|
||||
try {
|
||||
const metadata = await loadFilingMetadata(repository);
|
||||
if (metadata.status !== 'ready' || elements.getRepository() !== repository) return;
|
||||
renderLabels(repository, metadata.labels, selected.labelIds || []);
|
||||
renderMilestones(repository, metadata.milestones, selected.milestoneId);
|
||||
templatePicker.render(repository, metadata.templates, selected.templateId);
|
||||
} catch (_error) {
|
||||
if (elements.getRepository() !== repository) return;
|
||||
renderLabels(repository, {available:false}, selected.labelIds || []);
|
||||
renderMilestones(repository, {available:false}, selected.milestoneId);
|
||||
templatePicker.render(repository, {available:false}, selected.templateId);
|
||||
}
|
||||
}
|
||||
return {load};
|
||||
}
|
||||
|
||||
function loadRepositoryPage(page) {
|
||||
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
||||
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
||||
@@ -216,6 +657,21 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
}
|
||||
|
||||
async function searchBlockers(value) {
|
||||
const query = String(value || '').trim().slice(0, 80);
|
||||
const request = ++blockerSearchRequest;
|
||||
if (query.length < 2) return {status:'idle', items:[]};
|
||||
try {
|
||||
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(query) + '&limit=20');
|
||||
if (request !== blockerSearchRequest) return {status:'stale', items:[]};
|
||||
return {status:'ready', items:(Array.isArray(payload?.items) ? payload.items : [])
|
||||
.filter(item => item?.kind === 'issue' && item?.state === 'open').slice(0, 20)};
|
||||
} catch (error) {
|
||||
if (request !== blockerSearchRequest) return {status:'stale', items:[]};
|
||||
return {status:'failed', items:[], error};
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateKey(draft) {
|
||||
const repository = String(draft?.repository || '').trim();
|
||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||
@@ -276,6 +732,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: saved.title, body: saved.body, label_ids: saved.labelIds,
|
||||
...(saved.unassigned ? {unassigned:true} : {}),
|
||||
...(saved.assignee ? {assignee: saved.assignee} : {}),
|
||||
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
|
||||
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
|
||||
}),
|
||||
@@ -287,8 +745,10 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
|
||||
searchRepositories, findDuplicates,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadTemplates,
|
||||
loadFilingMetadata, applyTemplate, setTemplateState, templateFields, restoreTemplate, loadTemplateOptions,
|
||||
selectTemplate, buildRelatedDraft:relatedDraft, bindTemplatePicker, bindFilingMetadata, loadRepositoryPage,
|
||||
searchRepositories, searchBlockers, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
|
||||
@@ -296,5 +756,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
||||
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
|
||||
createIssueCapture.buildRelatedDraft = buildRelatedDraft;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
+104
-5
@@ -12,6 +12,13 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
|
||||
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
|
||||
.issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.issue-filing-receipt[hidden] { display:none; }
|
||||
.issue-filing-receipt-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #4ade80; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.issue-filing-receipt-panel h2, .issue-filing-receipt-panel h3 { margin:.25rem 0; }
|
||||
.issue-filing-receipt-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
.issue-filing-receipt-actions button, .issue-filing-receipt-actions .button-link { box-sizing:border-box; display:flex; align-items:center; justify-content:center; min-width:0; min-height:44px; width:100%; text-align:center; }
|
||||
@media (max-width:359px) { .issue-filing-receipt-actions { grid-template-columns:1fr; } }
|
||||
.live-data-status-feeds { display:grid; gap:8px; margin:14px 0; }
|
||||
.live-data-status-feed { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.live-data-status-feed strong, .live-data-status-feed span { overflow-wrap:anywhere; }
|
||||
@@ -121,6 +128,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.cmd-search-scope select { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
|
||||
.cmd-search-scope input { min-width:0; padding:7px; border-radius:8px; border:1px solid #31577f; background:#0b1526; color:#e5e7eb; }
|
||||
.cmd-repository-status { grid-column:2 / -1; min-height:16px; color:#93a4b8; font-size:12px; }
|
||||
.saved-searches { margin-top:8px; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
|
||||
.saved-search-header, .saved-search-create, .saved-search-row, .saved-search-row-actions { display:flex; align-items:center; gap:8px; }
|
||||
.saved-search-header { justify-content:space-between; }
|
||||
.saved-search-create { margin-top:6px; }
|
||||
.saved-search-create input { flex:1; min-width:0; }
|
||||
.saved-search-list { display:grid; gap:4px; margin-top:6px; }
|
||||
.saved-search-row { justify-content:space-between; border-top:1px solid #1f3a5f; padding-top:4px; }
|
||||
.saved-search-open { flex:1; min-width:0; text-align:left; }
|
||||
.saved-search-action { min-height:44px; }
|
||||
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
|
||||
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||
@@ -129,11 +145,27 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.cmd-select-result { min-height:44px; width:100%; display:grid; grid-template-columns:28px 1fr auto; gap:10px; align-items:center; text-align:left; }
|
||||
.cmd-select-result input { width:22px; height:22px; }
|
||||
.cmd-select-result[disabled] { cursor:not-allowed; opacity:.62; }
|
||||
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||
.search-batch-actions { position:sticky; bottom:0; z-index:3; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||
.search-batch-actions[hidden] { display:none; }
|
||||
.search-batch-actions span { grid-column:1 / -1; }
|
||||
.search-batch-actions button, .search-batch-recovery { min-height:44px; }
|
||||
.search-batch-recovery { width:100%; font-weight:700; }
|
||||
.search-batch-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.search-batch-estimate-review[hidden] { display:none; }
|
||||
.search-batch-estimate-review > div:first-child, #search-batch-estimate-list { display:grid; gap:8px; }
|
||||
.search-batch-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
|
||||
.search-batch-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.search-batch-estimate-review input { min-height:44px; width:76px; }
|
||||
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-batch-estimate-actions button { min-height:44px; }
|
||||
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.search-release-review[hidden] { display:none; }
|
||||
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
|
||||
.search-release-review label { display:grid; gap:6px; }
|
||||
.search-release-review select, .search-release-review input { min-height:44px; width:100%; }
|
||||
.search-release-row { padding:8px 0; border-bottom:1px solid #2a496e; }
|
||||
.search-release-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.search-release-actions button { min-height:44px; }
|
||||
|
||||
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
|
||||
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
||||
@@ -257,7 +289,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.draft-actions button { min-height:44px; width:100%; }
|
||||
.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px; width:100%; }
|
||||
.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.delivery-center h3, .delivery-center p { margin:0; }
|
||||
.delivery-center button { min-height:44px; flex:0 0 auto; }
|
||||
@@ -265,18 +297,19 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; }
|
||||
.draft-section > h3 { grid-column:1/-1; margin:8px 0 0; }
|
||||
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
|
||||
.create-issue-actions button { min-height:44px; width:100%; }
|
||||
.create-issue-actions #create-issue-status { grid-column:1/-1; }
|
||||
.create-issue-estimate { display:grid; gap:6px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#101d31; }
|
||||
.create-issue-estimate > span:first-of-type { display:flex; align-items:center; gap:8px; }
|
||||
.create-issue-estimate input { min-height:44px; width:min(9rem,60vw); }
|
||||
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
|
||||
.draft-filing-session[hidden] { display:none; }
|
||||
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.draft-filing-session-actions button { min-height:44px; width:100%; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; }
|
||||
.agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.agenda-replan-launch p { margin:4px 0 0; }
|
||||
.agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; }
|
||||
.agenda-replan-actions button { min-height:44px; width:100%; }
|
||||
.agenda-replan-actions label { grid-column:1/-1; }
|
||||
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; }
|
||||
.protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
@@ -488,6 +521,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
|
||||
#issue-sheet.read-only .issue-comment-composer,
|
||||
#issue-sheet.read-only #issue-planning,
|
||||
#issue-sheet.read-only #issue-handoff,
|
||||
#issue-sheet.read-only #release-issue,
|
||||
#issue-sheet.read-only #close-issue,
|
||||
#issue-sheet.read-only .detail-defer,
|
||||
#issue-sheet.read-only #issue-blockers,
|
||||
#issue-sheet.read-only #load-older-issue-comments,
|
||||
#issue-sheet.read-only #retry-issue-comment-actions { display:none; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
@@ -597,6 +639,49 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-filing { display:grid; gap:12px; min-width:0; }
|
||||
.create-issue-filing[hidden] { display:none; }
|
||||
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
||||
#create-issue-attachment-preview { grid-template-columns:1fr; }
|
||||
#create-issue-attachment-image { grid-row:auto; width:100%; height:auto; max-height:320px; }
|
||||
.issue-evidence-tray { display:flex; gap:8px; max-width:100%; padding:2px 0 6px; overflow-x:auto; overscroll-behavior-inline:contain; scrollbar-width:thin; }
|
||||
.issue-evidence-tray[hidden] { display:none; }
|
||||
.issue-evidence-thumbnail { position:relative; flex:0 0 64px; min-width:44px; min-height:64px; padding:3px; border:2px solid transparent; border-radius:10px; background:#10213a; }
|
||||
.issue-evidence-thumbnail[aria-pressed="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||
.issue-evidence-thumbnail img { display:block; width:56px; height:56px; border-radius:6px; object-fit:cover; }
|
||||
.issue-evidence-thumbnail span { position:absolute; right:3px; bottom:3px; min-width:20px; padding:1px 4px; border-radius:999px; background:#07101d; color:#fff; font-size:12px; text-align:center; }
|
||||
.issue-evidence-note { display:grid; gap:6px; min-width:0; }
|
||||
.issue-evidence-note textarea { box-sizing:border-box; width:100%; min-height:72px; padding:10px; resize:vertical; border:1px solid #2a496e; border-radius:8px; background:#07101d; color:#e5e7eb; font:inherit; }
|
||||
.issue-evidence-note textarea:focus-visible { outline:2px solid #60a5fa; outline-offset:2px; }
|
||||
.issue-evidence-review-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.issue-evidence-review-actions button { min-width:44px; min-height:44px; }
|
||||
.issue-evidence-editor { position:fixed; inset:0; z-index:70; display:grid; place-items:end center; overflow-x:hidden; background:rgba(2,6,15,.92); }
|
||||
.issue-evidence-editor[hidden] { display:none; }
|
||||
.issue-evidence-editor-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; display:grid; gap:12px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); background:#0b1526; border:1px solid #2a496e; border-radius:14px 14px 0 0; }
|
||||
.issue-evidence-editor-panel header { display:flex; align-items:start; justify-content:space-between; gap:12px; }
|
||||
.issue-evidence-editor-panel h3 { margin:0 0 4px; }
|
||||
.issue-evidence-editor canvas { display:block; max-width:100%; width:100%; max-height:60dvh; object-fit:contain; touch-action:none; border:1px solid #365b86; border-radius:8px; background:#020617; }
|
||||
.issue-evidence-editor-tools { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
|
||||
.issue-evidence-editor button { min-width:44px; min-height:44px; }
|
||||
.issue-evidence-editor button[aria-pressed="true"] { border-color:#60a5fa; background:#173a64; color:#fff; }
|
||||
.issue-evidence-editor-apply { width:100%; }
|
||||
@media(max-width:320px) { .issue-evidence-editor-panel { padding:12px; } .issue-evidence-editor-tools { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
.issue-filing-review { position:fixed; inset:0; z-index:72; display:grid; place-items:end center; overflow-x:hidden; background:rgba(2,6,15,.92); }
|
||||
.issue-filing-review[hidden] { display:none; }
|
||||
.issue-filing-review-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.issue-filing-review-panel h2, .issue-filing-review-panel h3, .issue-filing-review-panel p { margin:.2rem 0; }
|
||||
.issue-filing-review-summary { display:grid; gap:10px; margin:0; min-width:0; }
|
||||
.issue-filing-review-summary > div { display:grid; gap:4px; padding:10px; border:1px solid #2a496e; border-radius:10px; min-width:0; }
|
||||
.issue-filing-review-summary dt { color:#94a3b8; font-size:12px; font-weight:700; text-transform:uppercase; }
|
||||
.issue-filing-review-summary dd { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.issue-filing-review ol { display:grid; grid-template-columns:repeat(auto-fit,minmax(72px,1fr)); gap:8px; margin:0; padding:0; list-style:none; overflow-wrap:anywhere; }
|
||||
.issue-filing-evidence-selector { min-height:44px; width:100%; padding:4px; border:2px solid #31577f; border-radius:10px; background:#08111f; }
|
||||
.issue-filing-evidence-selector[aria-pressed="true"] { border-color:#93c5fd; box-shadow:0 0 0 2px rgba(96,165,250,.35); }
|
||||
.issue-filing-evidence-selector img { display:block; width:100%; height:64px; object-fit:cover; border-radius:6px; }
|
||||
.issue-filing-evidence-preview { display:grid; gap:8px; margin:10px 0 0; padding:10px; border:1px solid #31577f; border-radius:12px; background:#020617; }
|
||||
.issue-filing-evidence-preview[hidden] { display:none; }
|
||||
.issue-filing-evidence-preview > img { display:block; width:100%; max-height:42dvh; object-fit:contain; border-radius:8px; background:#000; }
|
||||
.issue-filing-evidence-preview figcaption { display:grid; gap:3px; min-width:0; overflow-wrap:anywhere; }
|
||||
.issue-filing-review-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0 calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.issue-filing-review-actions button { min-height:44px; width:100%; }
|
||||
@media(max-width:320px) { .issue-filing-review-panel { padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); } }
|
||||
.create-issue-repository-more { min-height:44px; width:100%; }
|
||||
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
|
||||
.create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
@@ -604,8 +689,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
#create-issue-repository-results[hidden] { display:none; }
|
||||
.create-issue-repository-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
|
||||
.create-issue-repository-result:last-child { border-bottom:0; }
|
||||
.create-issue-blockers { display:grid; gap:8px; min-width:0; }
|
||||
#create-issue-blocker-search { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
#create-issue-blocker-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; }
|
||||
#create-issue-blocker-results[hidden] { display:none; }
|
||||
.create-issue-blocker-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
|
||||
#create-issue-blocker-selected { display:grid; gap:8px; margin:0; padding:0; list-style:none; min-width:0; }
|
||||
.create-issue-blocker-selected { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; min-width:0; overflow-wrap:anywhere; }
|
||||
.create-issue-blocker-selected button { min-height:44px; }
|
||||
.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-owner { min-width:0; }
|
||||
.create-issue-owner select { min-height:44px; width:100%; }
|
||||
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
|
||||
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
@@ -628,6 +723,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
|
||||
.conversation-more { min-height:44px; width:100%; margin:8px 0; }
|
||||
.conversation-actions-retry { min-height:44px; max-width:100%; margin:8px 0; }
|
||||
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
|
||||
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
|
||||
.pull-review-file { margin-top:8px; }
|
||||
@@ -717,6 +813,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.cmd-search-scope { grid-template-columns:auto minmax(0,1fr); }
|
||||
#cmd-search-kind, #cmd-search-state { min-height:44px; width:100%; }
|
||||
#cmd-search-repository { min-height:44px; width:100%; }
|
||||
.saved-search-create { align-items:stretch; }
|
||||
.saved-search-create input, .saved-search-open, .saved-search-row-actions button { min-height:44px; }
|
||||
.saved-search-row { align-items:stretch; }
|
||||
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
|
||||
+404
-159
@@ -59,10 +59,23 @@
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
let mobileQueueCounts = {};
|
||||
function openFiledFollowUp() {
|
||||
selectMobileQueue('filed');
|
||||
const target = filedFollowUpTarget(lastMyWork);
|
||||
if (!target) {
|
||||
qs('#my-work-action-status').textContent = 'No filed issues are ready to open.';
|
||||
return 'empty';
|
||||
}
|
||||
const index = lastMyWork.indexOf(target.item);
|
||||
const trigger = qs('#my-work-list [data-' + target.kind + '-index="' + index + '"]');
|
||||
openRoutedWork(target.kind === 'update' ? { ...target.item, kind:'update' } : target.item, trigger);
|
||||
return target.kind === 'update' ? 'opened-update' : 'opened-issue';
|
||||
}
|
||||
const mobileQueueLauncher = createMobileQueueLauncher({
|
||||
openToday: () => mobileWorkEntry.open(),
|
||||
openAgenda: openAgendaSession,
|
||||
openUpdates: openUpdateTriage,
|
||||
openFiled: openFiledFollowUp,
|
||||
selectFilter: selectMobileQueue,
|
||||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
@@ -149,7 +162,7 @@
|
||||
let launchFilterResolved = false;
|
||||
try {
|
||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||
if (['all', 'today', 'agenda', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
|
||||
if (['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
|
||||
selectedWorkFilter = savedFilter;
|
||||
savedWorkFilter = savedFilter;
|
||||
launchFilterResolved = true;
|
||||
@@ -187,6 +200,7 @@
|
||||
let pullReviewState = null;
|
||||
let creatingIssue = false;
|
||||
let createAndStartRequested = false;
|
||||
let pendingIssueFilingIntent = 'create-and-assign';
|
||||
let findingWork = false;
|
||||
let availablePagination = { page: 1, total: 0, has_more: false };
|
||||
let progress = null;
|
||||
@@ -303,6 +317,7 @@
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
|
||||
error.status = response.status;
|
||||
|
||||
error.code = payload.detail?.code;
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
error.retryAfter = retryAfter === null ? undefined : Number(retryAfter);
|
||||
@@ -479,9 +494,30 @@
|
||||
image: qs('#create-issue-attachment-image'),
|
||||
meta: qs('#create-issue-attachment-meta'),
|
||||
remove: qs('#remove-create-issue-attachment'),
|
||||
tray: qs('#create-issue-evidence-tray'),
|
||||
earlier: qs('#move-create-issue-attachment-earlier'),
|
||||
later: qs('#move-create-issue-attachment-later'),
|
||||
note: qs('#create-issue-evidence-note'),
|
||||
noteLabel: qs('#create-issue-evidence-note-label'),
|
||||
status: qs('#create-issue-attachment-status'),
|
||||
readyMessage: 'Screenshot ready to file with this issue.',
|
||||
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
|
||||
editor: {
|
||||
document,
|
||||
edit: qs('#edit-create-issue-attachment'),
|
||||
dialog: qs('#issue-evidence-editor'),
|
||||
canvas: qs('#issue-evidence-editor-canvas'),
|
||||
exportCanvas: qs('#issue-evidence-editor-export'),
|
||||
crop: qs('#crop-issue-evidence'),
|
||||
redact: qs('#redact-issue-evidence'),
|
||||
highlight: qs('#highlight-issue-evidence'),
|
||||
arrow: qs('#arrow-issue-evidence'),
|
||||
undo: qs('#undo-issue-evidence-edit'),
|
||||
reset: qs('#reset-issue-evidence-edit'),
|
||||
cancel: qs('#cancel-issue-evidence-edit'),
|
||||
apply: qs('#apply-issue-evidence-edit'),
|
||||
status: qs('#issue-evidence-editor-status'),
|
||||
},
|
||||
createObjectURL: file => URL.createObjectURL(file),
|
||||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||||
readDataUrl: file => new Promise((resolve, reject) => {
|
||||
@@ -497,6 +533,9 @@
|
||||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
});
|
||||
let issueCapture = null;
|
||||
let issueOwnerPicker = null;
|
||||
let issueTemplatePicker = null;
|
||||
let issueFilingMetadata = null;
|
||||
let updateFollowUp = null;
|
||||
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
||||
const unfiledCaptures = createUnfiledCaptures({
|
||||
@@ -505,11 +544,16 @@
|
||||
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
||||
getCurrentLogin: () => activeFlushLogin,
|
||||
});
|
||||
createUnfiledDraftSync(
|
||||
unfiledCaptures, fetchReviewJson
|
||||
);
|
||||
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
|
||||
dFS.attach(qs, {
|
||||
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
|
||||
captures:unfiledCaptures, capture:()=>issueCapture, attachment:createIssueAttachmentController,
|
||||
getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; },
|
||||
openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode,
|
||||
review:r=>loadIssueFilingMetadata(r.repository,r)
|
||||
.then(()=>issueCapture.findDuplicates(currentIssueCaptureDraft())).then(renderIssueDuplicates),
|
||||
});
|
||||
let rUC = '';
|
||||
let backgroundIssueSync = null;
|
||||
@@ -530,6 +574,30 @@
|
||||
backgroundSync: backgroundIssueSync,
|
||||
getOwnerLogin: () => confirmedOwnerLogin,
|
||||
});
|
||||
const filingReview = createIssueFilingReview({
|
||||
sheet: qs('#issue-filing-review'),
|
||||
confirmButton: qs('#confirm-issue-filing'),
|
||||
backButton: qs('#back-to-issue-edit'),
|
||||
evidenceList: qs('#issue-filing-review-evidence'),
|
||||
evidenceEmpty: qs('#issue-filing-review-evidence-empty'),
|
||||
evidencePreview: qs('#issue-filing-review-evidence-preview'),
|
||||
evidenceImage: qs('#issue-filing-review-evidence-image'),
|
||||
evidencePosition: qs('#issue-filing-review-evidence-position'),
|
||||
evidenceFilename: qs('#issue-filing-review-evidence-filename'),
|
||||
evidenceNote: qs('#issue-filing-review-evidence-note'),
|
||||
repository: qs('#issue-filing-review-repository'),
|
||||
intent: qs('#issue-filing-review-intent'),
|
||||
issueType: qs('#issue-filing-review-template'),
|
||||
title: qs('#issue-filing-review-title'),
|
||||
body: qs('#issue-filing-review-body'),
|
||||
metadata: qs('#issue-filing-review-metadata'),
|
||||
blockerList: qs('#issue-filing-review-blockers'),
|
||||
status: qs('#issue-filing-review-status'),
|
||||
document,
|
||||
createObjectURL: blob => URL.createObjectURL(blob),
|
||||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||||
onConfirm: admitReviewedIssue,
|
||||
});
|
||||
const authoredOutbox = createAuthoredOutbox({
|
||||
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
||||
backgroundSync: backgroundIssueSync,
|
||||
@@ -570,14 +638,16 @@
|
||||
'comment-actions': document.querySelector('meta[name="stackchain-feature-comment-actions"]')?.content || '',
|
||||
},
|
||||
});
|
||||
await commentActionFeatures.run('comment-actions', {
|
||||
status: qs('#my-work-action-status'), retryLabel:'Reload to retry comment actions.',
|
||||
}, () => {
|
||||
commentActions = createCommentActions({
|
||||
fetchJson: fetchReviewJson,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
confirmDelete: message => window.confirm(message),
|
||||
});
|
||||
const commentActionHydrator = createConversationActionHydrator({
|
||||
load: () => commentActionFeatures.load('comment-actions'),
|
||||
activate: () => {
|
||||
commentActions = createCommentActions({
|
||||
fetchJson: fetchReviewJson,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
confirmDelete: message => window.confirm(message),
|
||||
});
|
||||
return commentActions;
|
||||
},
|
||||
});
|
||||
const issueCaptureFeatures = createFeatureLoader({
|
||||
document,
|
||||
@@ -595,6 +665,27 @@
|
||||
}, () => {
|
||||
if (!issueCapture) {
|
||||
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
|
||||
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
|
||||
issueTemplatePicker = issueCapture.bindTemplatePicker({
|
||||
field:qs('#create-issue-template-field'), select:qs('#create-issue-template'),
|
||||
status:qs('#create-issue-template-status'), document,
|
||||
getRepository:()=>qs('#create-issue-repository').value,
|
||||
labelInputs:()=>document.querySelectorAll('input[name="create-issue-label"]'),
|
||||
}, {
|
||||
getDraft:currentIssueCaptureDraft,
|
||||
setDraft:draft=>{ qs('#create-issue-title').value=draft.title; qs('#create-issue-body').value=draft.body; },
|
||||
changed:()=>{ saveIssueCaptureDraft(); scheduleIssueDuplicateCheck(); },
|
||||
});
|
||||
issueFilingMetadata = issueCapture.bindFilingMetadata({
|
||||
document,
|
||||
labelList:qs('#create-issue-label-list'),
|
||||
labelStatus:qs('#create-issue-label-status'),
|
||||
milestoneSelect:qs('#create-issue-milestone'),
|
||||
milestoneStatus:qs('#create-issue-milestone-status'),
|
||||
templateStatus:qs('#create-issue-template-status'),
|
||||
getRepository:()=>qs('#create-issue-repository').value,
|
||||
}, issueTemplatePicker);
|
||||
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
|
||||
}
|
||||
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
||||
@@ -855,7 +946,7 @@
|
||||
load: fetchWorkPage,
|
||||
onItems: (stream, items) => {
|
||||
if (!lastContextSnapshot) return;
|
||||
if (stream === 'issue') lastContextSnapshot.issues = items;
|
||||
if (stream === 'issue' || stream === 'filed') lastContextSnapshot.issues = items;
|
||||
else lastContextSnapshot.pull_requests = items;
|
||||
paintMyWork(lastContextSnapshot);
|
||||
},
|
||||
@@ -1191,7 +1282,7 @@
|
||||
closeOpenWorkSheets();
|
||||
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
|
||||
else if (item.kind === 'review') openReviewSheet(item, reviewTrigger);
|
||||
else if (item.kind === 'issue') openIssueSheet(item, issueTrigger);
|
||||
else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);
|
||||
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
|
||||
},
|
||||
onQueue: openWorkQueueRoute,
|
||||
@@ -1522,6 +1613,12 @@
|
||||
},
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
function renderCreateStartCapacity() {
|
||||
const result = createAndStart.capacity(qs('#create-issue-estimate').value);
|
||||
qs('#create-issue-estimate-status').textContent = createAndStart.capacityMessage(result);
|
||||
return result;
|
||||
}
|
||||
qs('#create-issue-estimate').addEventListener('input', () => { renderCreateStartCapacity(); saveIssueCaptureDraft(); });
|
||||
|
||||
const queueToday = createQueueToday({
|
||||
todayWork,
|
||||
@@ -2420,7 +2517,7 @@
|
||||
|
||||
function listDrafts() {
|
||||
const unfiled = unfiledCaptures.list().map(item => ({
|
||||
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
|
||||
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', ...unfiledDraftSummary(item),
|
||||
title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''),
|
||||
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
|
||||
updated_at:item.savedAt, quarantined:item.quarantined,
|
||||
@@ -2508,10 +2605,11 @@
|
||||
if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review'];
|
||||
if (selectedWorkFilter === 'agenda') return ['issue'];
|
||||
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
|
||||
if (selectedWorkFilter === 'filed') return ['filed'];
|
||||
if (selectedWorkFilter === 'issue') return ['issue'];
|
||||
if (selectedWorkFilter === 'pull') return ['pull'];
|
||||
if (selectedWorkFilter === 'review') return ['review'];
|
||||
if (selectedWorkFilter === 'all') return ['issue', 'pull', 'review'];
|
||||
if (selectedWorkFilter === 'all') return ['issue', 'filed', 'pull', 'review'];
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -2573,19 +2671,16 @@
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open review</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
|
||||
(isUnfiled ? 'Choose repository' : 'Resume draft') + '</button>' +
|
||||
(isUnfiled ? item.action : 'Resume draft') + '</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
||||
const state = (isOutbox || isUnfiled) ?
|
||||
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
||||
(isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' :
|
||||
item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' :
|
||||
item.status === 'authorization' ? 'Awaiting authorization' : 'Queued for sync')) + '</span>' +
|
||||
const state = isOutbox ?
|
||||
'<span class="pill">' + draftInbox.deliveryLabel(item) + '</span>' +
|
||||
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
||||
const attempt = item.last_attempt_error ? '<span class="delivery-attempt small">Last attempt ' +
|
||||
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '</span>' : '';
|
||||
const captureTarget = isUnfiled ? ' tabindex="-1" data-capture-id="' + escapeHtml(item.capture_id) + '"' : '';
|
||||
return '<article class="my-work-card draft-card"' + captureTarget + '>' +
|
||||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||
'<span class="small">' + escapeHtml([item.label, item.repository, item.details].filter(Boolean).join(' · ')) + '</span>' +
|
||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
|
||||
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
|
||||
@@ -2623,8 +2718,7 @@
|
||||
if (!item) return;
|
||||
if (item.kind === 'unfiled-issue') {
|
||||
try {
|
||||
dFS.start(item.capture_id);
|
||||
await dFS.nextCapture();
|
||||
await dFS.open(item.capture_id, item.ready ? button : null);
|
||||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||
else if (item.route) workRoute.open(item.route);
|
||||
@@ -2652,7 +2746,9 @@
|
||||
editingOutboxId = hydrated.id;
|
||||
issueCapture.saveDraft(hydrated);
|
||||
openCreateIssueSheet();
|
||||
if (hydrated.attachment) createIssueAttachmentController.restore(hydrated.attachment);
|
||||
if (hydrated.attachments || hydrated.attachment) {
|
||||
createIssueAttachmentController.restore(hydrated.attachments || hydrated.attachment);
|
||||
}
|
||||
else createIssueAttachmentController.clear();
|
||||
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
|
||||
} catch (error) {
|
||||
@@ -2702,7 +2798,9 @@
|
||||
button.addEventListener('click', async () => {
|
||||
if (!window.confirm('Discard this unfinished draft?')) return;
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id);
|
||||
if (item?.kind === 'unfiled-issue') {
|
||||
await unfiledCaptures.discard(item.capture_id);
|
||||
}
|
||||
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
||||
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
||||
else if (item) draftInbox.discard(item.id);
|
||||
@@ -3054,8 +3152,8 @@
|
||||
toggle?.focus();
|
||||
}
|
||||
|
||||
function renderIssueComment(comment) {
|
||||
const actions = commentActions.actionHtml?.(comment) || '';
|
||||
function renderIssueComment(comment, controller = commentActions) {
|
||||
const actions = controller?.actionHtml?.(comment) || '';
|
||||
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
|
||||
escapeHtml(comment.author || 'Unknown author') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
|
||||
@@ -3063,19 +3161,19 @@
|
||||
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
|
||||
}
|
||||
|
||||
function renderIssueConversation(state) {
|
||||
function paintIssueConversation(state, controller) {
|
||||
const comments = state?.comments || [];
|
||||
qs('#issue-comments').innerHTML = comments.length ?
|
||||
comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
|
||||
comments.map(comment => renderIssueComment(comment, controller)).join('') : '<div class="muted">No comments yet.</div>';
|
||||
qs('#load-older-issue-comments').hidden = !Number.isInteger(state?.older_page);
|
||||
qs('#issue-conversation-status').textContent = comments.length ?
|
||||
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
||||
}
|
||||
|
||||
function renderUpdateConversation(state) {
|
||||
function paintUpdateConversation(state, controller) {
|
||||
const comments = state?.comments || [];
|
||||
qs('#update-comments').innerHTML = comments.length ?
|
||||
comments.map(renderIssueComment).join('') : '<div class="muted">No comments yet.</div>';
|
||||
comments.map(comment => renderIssueComment(comment, controller)).join('') : '<div class="muted">No comments yet.</div>';
|
||||
qs('#load-older-update-comments').hidden = !Number.isInteger(state?.older_page);
|
||||
qs('#update-conversation-status').textContent = comments.length ?
|
||||
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
|
||||
@@ -3083,10 +3181,10 @@
|
||||
updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest);
|
||||
}
|
||||
|
||||
function renderPullConversation(state) {
|
||||
function paintPullConversation(state, controller) {
|
||||
const comments = state?.comments || [];
|
||||
qs('#pull-comments').innerHTML = comments.length ? comments.map(comment =>
|
||||
'<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>'
|
||||
'<div class="pull-comment-card">' + renderIssueComment(comment, controller) + '</div>'
|
||||
).join('') : '<div class="muted">No comments yet.</div>';
|
||||
qs('#load-older-pull-comments').hidden = !Number.isInteger(state?.older_page);
|
||||
qs('#pull-conversation-status').textContent = comments.length ?
|
||||
@@ -3108,16 +3206,47 @@
|
||||
};
|
||||
}
|
||||
|
||||
function wireCommentActions(selector) {
|
||||
commentActions.wire({
|
||||
function wireCommentActions(selector, controller = commentActions) {
|
||||
controller.wire({
|
||||
root:qs(selector), getSurface:()=>commentSurface(selector),
|
||||
isOffline:()=>offlineWorkMode || navigator.onLine === false, escapeHtml,
|
||||
});
|
||||
}
|
||||
|
||||
wireCommentActions('#issue-comments');
|
||||
wireCommentActions('#pull-comments');
|
||||
wireCommentActions('#update-comments');
|
||||
const conversationActionSurfaces = {
|
||||
issue: { selector:'#issue-comments', paint:paintIssueConversation },
|
||||
pull: { selector:'#pull-comments', paint:paintPullConversation },
|
||||
update: { selector:'#update-comments', paint:paintUpdateConversation },
|
||||
};
|
||||
const latestConversationStates = {};
|
||||
|
||||
function wireConversationActions(kind) {
|
||||
if (kind === 'issue') wireCommentActions('#issue-comments');
|
||||
if (kind === 'pull') wireCommentActions('#pull-comments');
|
||||
if (kind === 'update') wireCommentActions('#update-comments');
|
||||
}
|
||||
|
||||
function showConversationWithActions(kind, state) {
|
||||
const surface = conversationActionSurfaces[kind];
|
||||
latestConversationStates[kind] = state;
|
||||
return commentActionHydrator.show({
|
||||
root:qs(surface.selector), state, paint:surface.paint,
|
||||
retry:qs('#retry-' + kind + '-comment-actions'),
|
||||
wire:controller => wireConversationActions(kind, controller),
|
||||
});
|
||||
}
|
||||
|
||||
function renderIssueConversation(state) { void showConversationWithActions('issue', state); }
|
||||
function renderPullConversation(state) { void showConversationWithActions('pull', state); }
|
||||
function renderUpdateConversation(state) { void showConversationWithActions('update', state); }
|
||||
|
||||
function retryConversationActions(kind) {
|
||||
const state = latestConversationStates[kind];
|
||||
if (state) void showConversationWithActions(kind, state);
|
||||
}
|
||||
qs('#retry-issue-comment-actions').addEventListener('click', () => retryConversationActions('issue'));
|
||||
qs('#retry-pull-comment-actions').addEventListener('click', () => retryConversationActions('pull'));
|
||||
qs('#retry-update-comment-actions').addEventListener('click', () => retryConversationActions('update'));
|
||||
|
||||
function renderIssueLabelEditor(item, confirmedNames, labels) {
|
||||
const list = qs('#issue-label-list');
|
||||
@@ -3178,6 +3307,8 @@
|
||||
|
||||
async function openIssueSheet(item, trigger, offlineDetail = null) {
|
||||
if (!item) return;
|
||||
const readOnly = issueController.readOnly(item);
|
||||
qs('#issue-sheet').classList.toggle('read-only', readOnly);
|
||||
issueDetailPosition.open(workDetailIdentity('issue', item));
|
||||
qs('#issue-planning').inert = false;
|
||||
qs('#issue-handoff').inert = false;
|
||||
@@ -3246,9 +3377,12 @@
|
||||
).join(' ');
|
||||
qs('#issue-assignees').textContent = (detail.assignees || []).length ?
|
||||
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
|
||||
renderIssueConversation(issueConversation.snapshot());
|
||||
if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);
|
||||
else renderIssueConversation(issueConversation.snapshot());
|
||||
if (readOnly) qs('#load-older-issue-comments').hidden = true;
|
||||
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
||||
qs('#issue-sheet-status').textContent = 'Issue ready · ' + (detail.state || 'open');
|
||||
qs('#issue-sheet-status').textContent = readOnly ? 'Filed issue ready · read-only' :
|
||||
'Issue ready · ' + (detail.state || 'open');
|
||||
qs('#edit-issue-content').disabled = false;
|
||||
const dueDraft = issueController.loadDueDateDraft(item);
|
||||
qs('#issue-due-date').value = String(dueDraft || detail.due_date || '').slice(0, 10);
|
||||
@@ -3454,6 +3588,11 @@
|
||||
.map(input => Number(input.value)).filter(Number.isInteger);
|
||||
}
|
||||
|
||||
function selectedIssueLabelNames() {
|
||||
return Array.from(document.querySelectorAll('input[name="create-issue-label"]:checked'))
|
||||
.map(input => input.closest('label')?.querySelector('span')?.textContent?.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function renderAvailableIssues(items) {
|
||||
const list = qs('#find-work-list');
|
||||
const selection = findWorkController.selection();
|
||||
@@ -3587,33 +3726,23 @@
|
||||
qs('#find-work').focus();
|
||||
}
|
||||
|
||||
let issueCaptureBlockers = [];
|
||||
|
||||
function saveIssueCaptureDraft() {
|
||||
if (!issueCapture) return;
|
||||
issueCapture.saveDraft({
|
||||
repository: qs('#create-issue-repository').value,
|
||||
title: qs('#create-issue-title').value,
|
||||
body: qs('#create-issue-body').value,
|
||||
labelIds: selectedIssueLabelIds(),
|
||||
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
||||
dueDate: qs('#create-issue-due-date').value,
|
||||
});
|
||||
if (issueCapture) issueCapture.saveDraft(currentIssueCaptureDraft(false));
|
||||
}
|
||||
|
||||
function currentIssueCaptureDraft() {
|
||||
return {
|
||||
repository: qs('#create-issue-repository').value,
|
||||
title: qs('#create-issue-title').value.trim(),
|
||||
body: qs('#create-issue-body').value.trim(),
|
||||
labelIds: selectedIssueLabelIds(),
|
||||
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
||||
dueDate: qs('#create-issue-due-date').value,
|
||||
};
|
||||
function currentIssueCaptureDraft(trim = true) {
|
||||
return withFilingEstimate(issueTemplatePicker.fields(
|
||||
issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, trim)
|
||||
), qs('#create-issue-estimate').value);
|
||||
}
|
||||
|
||||
let issueCaptureRepositories = [];
|
||||
let nextIssueRepositoryPage = 2;
|
||||
let moreIssueRepositoriesAvailable = false;
|
||||
let issueRepositorySearchTimer = null;
|
||||
let captureBlockerSearchTimer = null;
|
||||
|
||||
function appendIssueRepositories(items) {
|
||||
const select = qs('#create-issue-repository');
|
||||
@@ -3633,9 +3762,56 @@
|
||||
}
|
||||
|
||||
function updateIssueCreateActions() {
|
||||
const hasRepository = Boolean(qs('#create-issue-repository').value);
|
||||
qs('#submit-new-issue').disabled = !hasRepository;
|
||||
qs('#create-and-start-issue').disabled = !hasRepository || !createAndStart.available();
|
||||
issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value),
|
||||
issueCaptureBlockers.length > 0, createAndStart.available());
|
||||
}
|
||||
|
||||
function renderIssueCaptureBlockers(blockers) {
|
||||
issueCaptureBlockers = Array.isArray(blockers) ? blockers.slice(0, 5) : [];
|
||||
const selected = qs('#create-issue-blocker-selected');
|
||||
selected.replaceChildren(...issueCaptureBlockers.map((blocker, index) => {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'create-issue-blocker-selected';
|
||||
const text = document.createElement('span');
|
||||
text.textContent = blocker.repository + ' #' + blocker.number + ' — ' + blocker.title;
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Remove';
|
||||
remove.setAttribute('aria-label', 'Remove blocker ' + blocker.repository + ' #' + blocker.number);
|
||||
remove.addEventListener('click', () => {
|
||||
renderIssueCaptureBlockers(issueCaptureBlockers.filter((_value, position) => position !== index));
|
||||
saveIssueCaptureDraft();
|
||||
});
|
||||
item.append(text, remove);
|
||||
return item;
|
||||
}));
|
||||
qs('#create-issue-blocker-status').textContent = issueCaptureBlockers.length ?
|
||||
issueCaptureBlockers.length + ' blocker' + (issueCaptureBlockers.length === 1 ? '' : 's') +
|
||||
' selected. Blocked work will be created without starting.' : 'No blockers selected.';
|
||||
updateIssueCreateActions();
|
||||
}
|
||||
|
||||
function renderIssueCaptureBlockerResults(items) {
|
||||
const results = qs('#create-issue-blocker-results');
|
||||
results.replaceChildren();
|
||||
(Array.isArray(items) ? items : []).filter(candidate => !issueCaptureBlockers.some(blocker =>
|
||||
blocker.repository === candidate.repository && blocker.number === Number(candidate.number))).forEach(candidate => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'create-issue-blocker-result';
|
||||
button.setAttribute('role', 'option');
|
||||
button.textContent = candidate.repository + ' #' + candidate.number + ' — ' + candidate.title;
|
||||
button.addEventListener('click', () => {
|
||||
renderIssueCaptureBlockers([...issueCaptureBlockers, {
|
||||
repository:candidate.repository, number:Number(candidate.number), title:candidate.title,
|
||||
}]);
|
||||
results.hidden = true;
|
||||
qs('#create-issue-blocker-search').value = '';
|
||||
saveIssueCaptureDraft();
|
||||
});
|
||||
results.appendChild(button);
|
||||
});
|
||||
results.hidden = !results.childElementCount;
|
||||
}
|
||||
|
||||
function renderIssueRepositoryResults(items) {
|
||||
@@ -3661,8 +3837,8 @@
|
||||
qs('#create-issue-repository-search').value = repository;
|
||||
qs('#create-issue-repository-results').hidden = true;
|
||||
qs('#create-issue-repository-status').textContent = 'Selected ' + repository + '.';
|
||||
loadIssueLabels(repository);
|
||||
loadIssueMilestones(repository);
|
||||
issueOwnerPicker.reset(repository);
|
||||
loadIssueFilingMetadata(repository);
|
||||
saveIssueCaptureDraft();
|
||||
scheduleIssueDuplicateCheck();
|
||||
updateIssueCreateActions();
|
||||
@@ -3719,51 +3895,8 @@
|
||||
}, 350);
|
||||
}
|
||||
|
||||
async function loadIssueLabels(repository, selectedIds = []) {
|
||||
const list = qs('#create-issue-label-list');
|
||||
const status = qs('#create-issue-label-status');
|
||||
list.innerHTML = '';
|
||||
if (!repository) {
|
||||
status.textContent = 'Choose a repository to load labels.';
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Loading labels…';
|
||||
try {
|
||||
const labels = await issueCapture.loadLabels(repository);
|
||||
const selected = new Set(selectedIds.map(Number));
|
||||
list.innerHTML = labels.map(label =>
|
||||
'<label class="create-issue-label-option"><input type="checkbox" name="create-issue-label" value="' +
|
||||
Number(label.id) + '"' + (selected.has(Number(label.id)) ? ' checked' : '') + '><span>' +
|
||||
escapeHtml(label.name) + '</span></label>'
|
||||
).join('');
|
||||
status.textContent = labels.length ? 'Select labels to triage this issue.' : 'This repository has no labels.';
|
||||
} catch (error) {
|
||||
status.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
|
||||
}
|
||||
}
|
||||
|
||||
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.';
|
||||
}
|
||||
async function loadIssueFilingMetadata(repository, selected = {}) {
|
||||
return issueFilingMetadata.load(repository, selected);
|
||||
}
|
||||
|
||||
function clearSharedLaunchUrl() {
|
||||
@@ -3792,6 +3925,7 @@
|
||||
return;
|
||||
}
|
||||
const captureDraft = issueCapture.loadDraft();
|
||||
issueTemplatePicker.reset(captureDraft);
|
||||
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
||||
if (!issueCaptureRepositories.length) issueCaptureRepositories = initialRepositories.slice();
|
||||
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
|
||||
@@ -3813,8 +3947,12 @@
|
||||
setIssueFilingMode(Boolean(captureDraft.repository));
|
||||
qs('#create-issue-capture-status').textContent = '';
|
||||
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
||||
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
||||
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||||
qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';
|
||||
issueOwnerPicker.reset(captureDraft.repository, captureDraft);
|
||||
renderIssueCaptureBlockers(captureDraft.blockers || []);
|
||||
qs('#create-issue-blocker-search').value = '';
|
||||
qs('#create-issue-blocker-results').hidden = true;
|
||||
loadIssueFilingMetadata(qs('#create-issue-repository').value, captureDraft);
|
||||
scheduleIssueDuplicateCheck();
|
||||
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
|
||||
qs('#create-follow-up-next').hidden = !updateFollowUp?.source();
|
||||
@@ -3839,12 +3977,32 @@
|
||||
}
|
||||
|
||||
function setIssueFilingMode(enabled) {
|
||||
qs('#create-issue-filing').hidden = !enabled;
|
||||
qs('.create-issue-capture-actions').hidden = enabled;
|
||||
qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work';
|
||||
applyIssueFilingMode(qs, enabled);
|
||||
}
|
||||
|
||||
let suppressCreateDraftOnHistoryClose = false;
|
||||
const issueFilingReceipt = createIssueFilingReceipt({
|
||||
root:qs('#issue-filing-receipt'), heading:qs('#issue-filing-receipt-heading'),
|
||||
key:qs('#issue-filing-receipt-key'), title:qs('#issue-filing-receipt-title'),
|
||||
ownership:qs('#issue-filing-receipt-ownership'), openLink:qs('#issue-filing-receipt-open'),
|
||||
filedButton:qs('#issue-filing-receipt-filed'),
|
||||
shareButton:qs('#issue-filing-receipt-share'), relatedButton:qs('#issue-filing-receipt-related'),
|
||||
fileAnotherButton:qs('#issue-filing-receipt-another'),
|
||||
doneButton:qs('#issue-filing-receipt-done'), status:qs('#issue-filing-receipt-status'),
|
||||
navigator,
|
||||
onFileRelated:plan => {
|
||||
issueCapture.saveDraft(plan);
|
||||
createIssueAttachmentController.clear();
|
||||
qs('#new-issue').click();
|
||||
},
|
||||
onFileAnother:() => qs('#new-issue').click(),
|
||||
onViewFiled:issue => {
|
||||
selectMobileQueue('filed');
|
||||
openRoutedWork({
|
||||
...issue, kind:'issue', is_filed:true, is_assigned:(issue.assignees || []).includes(confirmedOwnerLogin),
|
||||
});
|
||||
},
|
||||
});
|
||||
function closeCreateIssueSheet(navigate = true, preserveDraft = true) {
|
||||
if (navigate && taskOverlayHistory.current() === 'new') {
|
||||
suppressCreateDraftOnHistoryClose = !preserveDraft;
|
||||
@@ -3868,6 +4026,7 @@
|
||||
function applyOutboxResult(result, openCreated = false, startCreated = false) {
|
||||
if (result.lease_skipped) { refreshMyWorkView(); return; }
|
||||
(result.confirmed || []).forEach(confirmed => {
|
||||
confirmed.work_reasons = ['created_by_me'];
|
||||
if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
||||
});
|
||||
if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
|
||||
@@ -3878,7 +4037,7 @@
|
||||
const created = lastMyWork.find(item => item.kind === 'issue' &&
|
||||
item.repository === completion.issue?.repository && item.number === completion.issue?.number);
|
||||
if (!created) return;
|
||||
const outcome = createAndStart.complete(created);
|
||||
const outcome = createAndStart.complete(created, completion.estimateMinutes);
|
||||
if (outcome === 'started' || outcome === 'exists') {
|
||||
issueOutbox.completeIntent(completion.id, activeFlushLogin);
|
||||
startedCompletions.add(created.repository + '#' + created.number);
|
||||
@@ -3892,6 +4051,11 @@
|
||||
item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
|
||||
);
|
||||
const completionHandled = startedCompletions.has(confirmed.repository + '#' + confirmed.number);
|
||||
if (!(confirmed.assignees || []).includes(activeFlushLogin)) {
|
||||
const filing = (result.filings || []).find(candidate => candidate.issue?.repository === confirmed.repository &&
|
||||
candidate.issue?.number === confirmed.number);
|
||||
issueFilingReceipt.show(confirmed, qs('#new-issue'), filing?.relatedDraft);
|
||||
}
|
||||
if (startCreated && !(result.completions || []).length && created) {
|
||||
const outcome = createAndStart.complete(created);
|
||||
if (outcome !== 'started' && openCreated) openRoutedWork(created, qs('#new-issue'));
|
||||
@@ -4424,8 +4588,8 @@
|
||||
});
|
||||
searchBatchPlanning = mountSearchBatchPlanning(
|
||||
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
|
||||
searchPreviewPath, queueToday, acceptClaimedIssue, index=>commandItems[index]?.result,
|
||||
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr
|
||||
searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, i=>commandItems[i]?.result,
|
||||
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr, laterWork, laterPicker
|
||||
);
|
||||
searchDefer = createSearchDefer({
|
||||
claim: detail => searchPreview.claim(detail),
|
||||
@@ -4613,6 +4777,9 @@
|
||||
},
|
||||
});
|
||||
taskOverlayHistory.start();
|
||||
createSavedSearches.mount(
|
||||
document, fetch, commandSearch, applySearchScope, taskOverlayHistory
|
||||
).load();
|
||||
qs('#open-palette').addEventListener('click', openCommandPalette);
|
||||
qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close());
|
||||
qs('#cmd-load-more').addEventListener('click', () => commandSearch.loadMore());
|
||||
@@ -4932,12 +5099,10 @@
|
||||
});
|
||||
qs('#save-unfiled-issue').addEventListener('click', async () => {
|
||||
try {
|
||||
const captureDraft = {
|
||||
title: qs('#create-issue-title').value.trim(),
|
||||
body: qs('#create-issue-body').value.trim(),
|
||||
attachment: await createIssueAttachmentController.serialize(),
|
||||
};
|
||||
if (showDraftCapacityDialog(unfiledCaptures)) return;
|
||||
const evidence = await createIssueAttachmentController.serialize();
|
||||
const captureDraft = currentIssueCaptureDraft();
|
||||
Object.assign(captureDraft, Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence});
|
||||
if (showDraftCapacityDialog(unfiledCaptures, qs)) return;
|
||||
const savedCapture = await unfiledCaptures.save(captureDraft);
|
||||
if (rUC) {
|
||||
await unfiledCaptures.completeResume(rUC);
|
||||
@@ -4956,13 +5121,16 @@
|
||||
savedCard?.scrollIntoView({block:'nearest'});
|
||||
savedCard?.focus({preventScroll:true});
|
||||
});
|
||||
qs('#my-work-action-status').textContent = 'Saved to Drafts. Choose a repository when you’re ready to file it.';
|
||||
qs('#my-work-action-status').textContent = unfiledSavedMessage(captureDraft);
|
||||
} catch (error) {
|
||||
qs('#create-issue-capture-status').textContent = error.message;
|
||||
qs('#create-issue-title').focus();
|
||||
}
|
||||
});
|
||||
bindDraftCapacityDialog();
|
||||
bindDraftCapacityDialog({
|
||||
qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView,
|
||||
unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture,
|
||||
});
|
||||
qs('#use-shared-content').addEventListener('click', () => {
|
||||
if (issueCapture.pendingFollowUp()) issueCapture.acceptFollowUp();
|
||||
else issueCapture.acceptSharedContent();
|
||||
@@ -4997,8 +5165,9 @@
|
||||
})
|
||||
);
|
||||
qs('#create-issue-repository').addEventListener('change', event => {
|
||||
loadIssueLabels(event.target.value);
|
||||
loadIssueMilestones(event.target.value);
|
||||
issueTemplatePicker.changeRepository(currentIssueCaptureDraft());
|
||||
issueOwnerPicker.reset(event.target.value);
|
||||
loadIssueFilingMetadata(event.target.value);
|
||||
qs('#create-issue-repository-search').value = event.target.value;
|
||||
saveIssueCaptureDraft();
|
||||
scheduleIssueDuplicateCheck();
|
||||
@@ -5028,6 +5197,36 @@
|
||||
status.textContent = state.items.length ? 'Choose a matching repository.' : 'No accessible repositories match.';
|
||||
}, 250);
|
||||
});
|
||||
qs('#create-issue-blocker-search').addEventListener('input', event => {
|
||||
clearTimeout(captureBlockerSearchTimer);
|
||||
const query = event.target.value.trim();
|
||||
const results = qs('#create-issue-blocker-results');
|
||||
const status = qs('#create-issue-blocker-status');
|
||||
if (query.length < 2) {
|
||||
issueCapture.searchBlockers(query);
|
||||
results.hidden = true;
|
||||
status.textContent = issueCaptureBlockers.length ? issueCaptureBlockers.length + ' blocker(s) selected.' :
|
||||
(query ? 'Enter at least 2 characters to search.' : 'No blockers selected.');
|
||||
return;
|
||||
}
|
||||
if (issueCaptureBlockers.length >= 5) {
|
||||
results.hidden = true;
|
||||
status.textContent = 'Five blockers selected. Remove one to choose another.';
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Searching open issues…';
|
||||
captureBlockerSearchTimer = setTimeout(async () => {
|
||||
const state = await issueCapture.searchBlockers(query);
|
||||
if (state.status === 'stale') return;
|
||||
if (state.status === 'failed') {
|
||||
results.hidden = true;
|
||||
status.textContent = 'Blocker search failed. Your draft is safe; retry.';
|
||||
return;
|
||||
}
|
||||
renderIssueCaptureBlockerResults(state.items);
|
||||
status.textContent = state.items.length ? 'Choose an issue that must finish first.' : 'No open issues match.';
|
||||
}, 250);
|
||||
});
|
||||
qs('#load-more-issue-repositories').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
const status = qs('#create-issue-repository-status');
|
||||
@@ -5056,26 +5255,13 @@
|
||||
qs('#create-issue-anyway').hidden = true;
|
||||
qs('#create-issue-form').requestSubmit();
|
||||
});
|
||||
qs('#create-issue-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (event.submitter) createAndStartRequested = event.submitter?.id === 'create-and-start-issue';
|
||||
const followUpNextRequested = event.submitter?.id === 'create-follow-up-next';
|
||||
const captureDraft = currentIssueCaptureDraft();
|
||||
if (!captureDraft.repository || !captureDraft.title) {
|
||||
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
||||
qs('#create-issue-title').focus();
|
||||
return;
|
||||
}
|
||||
if (issueCapture.needsDuplicateAcknowledgement(captureDraft)) {
|
||||
qs('#create-issue-status').textContent = 'Review the possible existing issues, or choose Create anyway.';
|
||||
qs('#create-issue-anyway').hidden = false;
|
||||
qs('#create-issue-anyway').focus();
|
||||
return;
|
||||
}
|
||||
async function admitReviewedIssue(review) {
|
||||
const durableDraft = review.draft;
|
||||
const deliveryDraft = {...durableDraft, relatedDraft:issueCapture.buildRelatedDraft(durableDraft)};
|
||||
const followUpNextRequested = review.intent === 'follow-up-and-next';
|
||||
createAndStartRequested = review.intent === 'create-and-start';
|
||||
if (createAndStartRequested && !createAndStart.available()) {
|
||||
qs('#create-issue-status').textContent = 'Today is full. Remove an item before creating and starting another.';
|
||||
qs('#create-and-start-issue').focus();
|
||||
return;
|
||||
throw new Error('Today is full now. Go back and remove an item before creating and starting another.');
|
||||
}
|
||||
const button = qs('#submit-new-issue');
|
||||
const startButton = qs('#create-and-start-issue');
|
||||
@@ -5086,22 +5272,17 @@
|
||||
qs('#create-issue-status').textContent = createAndStartRequested ?
|
||||
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
|
||||
try {
|
||||
const durableDraft = {
|
||||
...captureDraft,
|
||||
attachment: await createIssueAttachmentController.serialize(),
|
||||
...(rUC ? { sourceCaptureId: rUC } : {}),
|
||||
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
|
||||
};
|
||||
const admission = followUpNextRequested ? (await updateFollowUp.complete({
|
||||
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||
issueOutbox.enqueueDurably(durableDraft),
|
||||
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, deliveryDraft) :
|
||||
issueOutbox.enqueueDurably(deliveryDraft),
|
||||
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
|
||||
advance: source => notificationReader.acceptReadAndNext(lastMyWork, source),
|
||||
})).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||
await issueOutbox.enqueueDurably(durableDraft));
|
||||
})).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, deliveryDraft) :
|
||||
await issueOutbox.enqueueDurably(deliveryDraft));
|
||||
const queued = admission.item;
|
||||
pendingIssueFilingIntent = 'create-and-assign';
|
||||
const fS = dFS.current();
|
||||
if (rUC && (!durableDraft.attachment || admission.background)) {
|
||||
if (rUC && ((!durableDraft.attachment && !durableDraft.attachments) || admission.background)) {
|
||||
await unfiledCaptures.completeResume(rUC);
|
||||
rUC = '';
|
||||
}
|
||||
@@ -5113,8 +5294,6 @@
|
||||
editingOutboxId = queued.id;
|
||||
refreshMyWorkView();
|
||||
qs('#create-issue-status').textContent = 'Saved for next launch; background delivery unavailable.';
|
||||
button.disabled = false;
|
||||
startButton.disabled = !createAndStart.available();
|
||||
return;
|
||||
}
|
||||
editingOutboxId = null;
|
||||
@@ -5132,9 +5311,75 @@
|
||||
createAndStartRequested = false;
|
||||
} catch (error) {
|
||||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
throw error;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
startButton.disabled = !createAndStart.available();
|
||||
followUpButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
qs('#create-issue-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (event.submitter) pendingIssueFilingIntent =
|
||||
event.submitter.id === 'create-and-start-issue' ? 'create-and-start' :
|
||||
(event.submitter.id === 'create-follow-up-next' ? 'follow-up-and-next' : 'create-and-assign');
|
||||
const intent = pendingIssueFilingIntent;
|
||||
createAndStartRequested = intent === 'create-and-start';
|
||||
const startCapacity = createAndStartRequested
|
||||
? createAndStart.capacity(qs('#create-issue-estimate').value) : null;
|
||||
const captureDraft = currentIssueCaptureDraft();
|
||||
if (!captureDraft.repository || !captureDraft.title) {
|
||||
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
||||
qs('#create-issue-title').focus();
|
||||
return;
|
||||
}
|
||||
if (issueCapture.needsDuplicateAcknowledgement(captureDraft)) {
|
||||
qs('#create-issue-status').textContent = 'Review the possible existing issues, or choose Create anyway.';
|
||||
qs('#create-issue-anyway').hidden = false;
|
||||
qs('#create-issue-anyway').focus();
|
||||
return;
|
||||
}
|
||||
if (createAndStartRequested && !createAndStart.available()) {
|
||||
qs('#create-issue-status').textContent = 'Today is full. Remove an item before creating and starting another.';
|
||||
qs('#create-and-start-issue').focus();
|
||||
return;
|
||||
}
|
||||
if (createAndStartRequested && !startCapacity.valid) {
|
||||
qs('#create-issue-status').textContent = startCapacity.reason === 'required'
|
||||
? 'Add a work estimate before creating and starting.'
|
||||
: 'Use a whole-number estimate from 5 to 1440 minutes.';
|
||||
qs('#create-issue-estimate').focus();
|
||||
renderCreateStartCapacity();
|
||||
return;
|
||||
}
|
||||
if (createAndStartRequested && !startCapacity.fits) {
|
||||
qs('#create-issue-status').textContent = 'This would put Today ' +
|
||||
Math.abs(startCapacity.projectedMinutes) + ' minutes over capacity. Adjust the estimate or Today plan.';
|
||||
qs('#create-issue-estimate').focus();
|
||||
renderCreateStartCapacity();
|
||||
return;
|
||||
}
|
||||
qs('#create-issue-status').textContent = 'Preparing complete filing review…';
|
||||
try {
|
||||
const evidence = await createIssueAttachmentController.serialize();
|
||||
const milestoneOption = qs('#create-issue-milestone').selectedOptions?.[0];
|
||||
const durableDraft = {
|
||||
...captureDraft,
|
||||
labels: selectedIssueLabelNames(),
|
||||
milestoneTitle: captureDraft.milestoneId ? milestoneOption?.textContent?.trim() : '',
|
||||
...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}),
|
||||
...(rUC ? { sourceCaptureId: rUC } : {}),
|
||||
...(createAndStartRequested ? {
|
||||
completionIntent: 'create-and-start', estimateMinutes: startCapacity.minutes,
|
||||
todayCapacity: startCapacity,
|
||||
} : {}),
|
||||
};
|
||||
filingReview.open({draft: durableDraft, intent},
|
||||
dFS.takeTrigger() || event.submitter || qs('#submit-new-issue'));
|
||||
qs('#create-issue-status').textContent = 'Review the complete payload, then confirm filing.';
|
||||
} catch (error) {
|
||||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
qs('#create-issue-title').focus();
|
||||
}
|
||||
});
|
||||
@@ -6404,7 +6649,7 @@
|
||||
if (!stream || !lastContextSnapshot) return;
|
||||
const button = qs('#load-more-work');
|
||||
button.disabled = true;
|
||||
const existing = stream === 'issue' ?
|
||||
const existing = stream === 'issue' || stream === 'filed' ?
|
||||
(lastContextSnapshot.issues || []) : (lastContextSnapshot.pull_requests || []);
|
||||
try {
|
||||
const loaded = await workPager.loadMore(stream, existing);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function showDraftCapacityDialog(captures) {
|
||||
function showDraftCapacityDialog(captures, qs) {
|
||||
const state = captures.capacity();
|
||||
if (!state.full) return false;
|
||||
const oldest = state.oldest;
|
||||
@@ -11,7 +11,10 @@ function showDraftCapacityDialog(captures) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindDraftCapacityDialog() {
|
||||
function bindDraftCapacityDialog({
|
||||
qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView,
|
||||
unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture,
|
||||
}) {
|
||||
qs('#keep-editing-draft').addEventListener('click', () => {
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
qs('#save-unfiled-issue').focus();
|
||||
@@ -40,10 +43,10 @@ function bindDraftCapacityDialog() {
|
||||
const button = qs('#replace-oldest-draft');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const draft = {
|
||||
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
|
||||
attachment:await createIssueAttachmentController.serialize(),
|
||||
};
|
||||
const draft = currentIssueCaptureDraft();
|
||||
const evidence = await createIssueAttachmentController.serialize();
|
||||
Object.assign(draft,
|
||||
Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence});
|
||||
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
issueCapture.clearDraft();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
function createDraftFilingSession({list}) {
|
||||
let orderedIds = [];
|
||||
let currentId = '';
|
||||
let reviewTrigger = null;
|
||||
|
||||
function visibleIds() {
|
||||
const available = new Set(list().map(item => item.id));
|
||||
@@ -49,17 +50,42 @@ function createDraftFilingSession({list}) {
|
||||
|
||||
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
|
||||
session.attach = (qs, dependencies) => {
|
||||
const openCapture = async captureId => {
|
||||
const openCapture = async (captureId, trigger = null) => {
|
||||
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
|
||||
dependencies.issueCapture.saveDraft(resumed);
|
||||
const issueCapture = dependencies.capture?.() || dependencies.issueCapture;
|
||||
issueCapture.saveDraft(resumed);
|
||||
dependencies.setResumedId(captureId);
|
||||
await dependencies.openSheet();
|
||||
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
|
||||
await dependencies.openSheet(false);
|
||||
const evidence = resumed.attachments || resumed.attachment;
|
||||
dependencies.attachment[evidence ? 'restore' : 'clear'](evidence);
|
||||
dependencies.setFilingMode(true);
|
||||
session.render();
|
||||
if (trigger) {
|
||||
await dependencies.review(resumed);
|
||||
reviewTrigger = trigger;
|
||||
const submitter = resumed.completionIntent === 'create-and-start' ?
|
||||
qs('#create-and-start-issue') : qs('#submit-new-issue');
|
||||
const disabled = submitter.disabled;
|
||||
submitter.disabled = false;
|
||||
qs('#create-issue-form').requestSubmit(submitter);
|
||||
submitter.disabled = disabled;
|
||||
}
|
||||
};
|
||||
return Object.assign(session, {
|
||||
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
|
||||
reviewCapture(captureId, trigger) {
|
||||
session.start(captureId);
|
||||
return openCapture(captureId, trigger);
|
||||
},
|
||||
open(captureId, trigger = null) {
|
||||
session.start(captureId);
|
||||
return openCapture(captureId, trigger);
|
||||
},
|
||||
takeTrigger() {
|
||||
const trigger = reviewTrigger;
|
||||
reviewTrigger = null;
|
||||
return trigger;
|
||||
},
|
||||
render() {
|
||||
const state = session.current();
|
||||
qs('#draft-filing-session').hidden = !state;
|
||||
|
||||
+10
-1
@@ -251,7 +251,16 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
return { drafts, deliveries, counts, retryable };
|
||||
}
|
||||
|
||||
return { list, discard, partition };
|
||||
function deliveryLabel(item) {
|
||||
if (item.quarantined) return 'Identity protected';
|
||||
if (item.status === 'completion') return 'Created · ready to start';
|
||||
if (item.status === 'attention') return 'Needs attention';
|
||||
if (item.status === 'sending') return 'Sending';
|
||||
if (item.status === 'authorization') return 'Awaiting authorization';
|
||||
return 'Queued for sync';
|
||||
}
|
||||
|
||||
return { list, discard, partition, deliveryLabel };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
|
||||
+167
-4
@@ -135,6 +135,7 @@
|
||||
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
|
||||
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">0</span></button>
|
||||
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
|
||||
<button class="work-filter" data-work-filter="filed" aria-pressed="false">Filed <span data-work-count="filed">0</span></button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||
@@ -252,6 +253,7 @@
|
||||
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
||||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
|
||||
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
||||
<div class="small" id="later-sync-status" aria-live="polite">Later is saved on this device.</div>
|
||||
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
|
||||
@@ -423,6 +425,25 @@
|
||||
<button id="add-plan-preview" type="button">Add to Today & back</button>
|
||||
</div>
|
||||
|
||||
<div class="issue-filing-receipt" id="issue-filing-receipt" role="dialog" aria-modal="true" aria-labelledby="issue-filing-receipt-heading" hidden>
|
||||
<section class="issue-filing-receipt-panel">
|
||||
<div class="small">Issue created</div>
|
||||
<h2 id="issue-filing-receipt-heading" tabindex="-1">Ready to hand off</h2>
|
||||
<div id="issue-filing-receipt-key" class="small"></div>
|
||||
<h3 id="issue-filing-receipt-title"></h3>
|
||||
<p id="issue-filing-receipt-ownership"></p>
|
||||
<p id="issue-filing-receipt-status" class="small" aria-live="polite"></p>
|
||||
<div class="issue-filing-receipt-actions">
|
||||
<button id="issue-filing-receipt-filed" type="button">View in Filed</button>
|
||||
<a id="issue-filing-receipt-open" class="button-link" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<button id="issue-filing-receipt-share" type="button">Share link</button>
|
||||
<button id="issue-filing-receipt-related" type="button">File related issue</button>
|
||||
<button id="issue-filing-receipt-another" type="button">File another</button>
|
||||
<button id="issue-filing-receipt-done" type="button">Done</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
||||
<div class="cmd-palette-header">
|
||||
<strong>Search work</strong>
|
||||
@@ -451,6 +472,18 @@
|
||||
<datalist id="cmd-search-repositories"></datalist>
|
||||
<span id="cmd-repository-status" class="cmd-repository-status" aria-live="polite"></span>
|
||||
</fieldset>
|
||||
<section class="saved-searches" aria-labelledby="saved-searches-heading">
|
||||
<div class="saved-search-header">
|
||||
<strong id="saved-searches-heading">Saved searches</strong>
|
||||
<span id="saved-search-status" class="small" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="saved-search-create">
|
||||
<label for="saved-search-name" class="sr-only">Saved search name</label>
|
||||
<input id="saved-search-name" type="text" maxlength="60" autocomplete="off" placeholder="Name this search" />
|
||||
<button id="save-current-search" class="saved-search-action" type="button">Save search</button>
|
||||
</div>
|
||||
<div id="saved-search-list" class="saved-search-list" aria-label="Saved searches"></div>
|
||||
</section>
|
||||
<div id="cmd-search-action-status" class="small" aria-live="polite"></div>
|
||||
<button id="resume-search-batch" class="search-batch-recovery" type="button" hidden>Resume batch</button>
|
||||
<div id="cmd-results" class="stack" role="listbox" aria-label="Commands and work search results"></div>
|
||||
@@ -458,8 +491,33 @@
|
||||
<div class="search-batch-actions" id="search-batch-actions" hidden>
|
||||
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
|
||||
<button id="cancel-search-selection" type="button">Cancel</button>
|
||||
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
|
||||
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
|
||||
<button id="queue-selected-search-results" type="button" disabled>Assign & add to Today</button>
|
||||
</div>
|
||||
<section class="search-batch-estimate-review" id="search-batch-estimate-review"
|
||||
aria-labelledby="search-batch-estimate-heading" hidden>
|
||||
<div><strong id="search-batch-estimate-heading">Fit this work into Today</strong>
|
||||
<span class="small" id="search-batch-estimate-summary" aria-live="polite"></span></div>
|
||||
<div id="search-batch-estimate-list"></div>
|
||||
<div class="search-batch-estimate-actions">
|
||||
<button id="cancel-search-batch-estimates" type="button">Back</button>
|
||||
<button id="confirm-search-batch-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="search-release-review" id="search-release-review"
|
||||
aria-labelledby="search-release-heading" hidden>
|
||||
<div><strong id="search-release-heading">Plan selected issues into a release</strong>
|
||||
<span class="small" id="search-release-summary" aria-live="polite"></span></div>
|
||||
<label>Open milestone<select id="search-release-milestone" required></select></label>
|
||||
<label>Shared due date <span class="small">(optional)</span>
|
||||
<input id="search-release-due-date" type="date" /></label>
|
||||
<div id="search-release-list"></div>
|
||||
<div class="search-release-actions">
|
||||
<button id="cancel-search-release" type="button">Back</button>
|
||||
<button id="confirm-search-release" type="button">Assign & plan</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="search-preview" id="search-preview" role="dialog" aria-modal="true" aria-labelledby="search-preview-title">
|
||||
@@ -558,6 +616,7 @@
|
||||
<div id="issue-comments"></div>
|
||||
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
|
||||
<div id="issue-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<button class="conversation-actions-retry" id="retry-issue-comment-actions" type="button" hidden>Retry comment tools</button>
|
||||
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
|
||||
<h2 id="issue-comment-title">Add comment</h2>
|
||||
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||
@@ -717,15 +776,25 @@
|
||||
<button id="file-new-issue" type="button">File now</button>
|
||||
</div>
|
||||
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
|
||||
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
|
||||
<strong id="create-issue-attachment-label">Evidence screenshots <span class="small">Optional · Up to 5 · PNG, JPEG, or WebP · 2 MB each</span></strong>
|
||||
<div class="issue-attachment-controls">
|
||||
<label class="issue-attachment-trigger" for="create-issue-attachment">Attach screenshot</label>
|
||||
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
<label class="issue-attachment-trigger" for="create-issue-attachment">Add screenshots</label>
|
||||
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
|
||||
</div>
|
||||
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
|
||||
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
|
||||
<span class="small" id="create-issue-attachment-meta"></span>
|
||||
<button id="remove-create-issue-attachment" type="button">Remove</button>
|
||||
<div class="issue-evidence-tray" id="create-issue-evidence-tray" role="toolbar" aria-label="Evidence screenshots" hidden></div>
|
||||
<label class="issue-evidence-note" for="create-issue-evidence-note">
|
||||
<span id="create-issue-evidence-note-label">Evidence note (optional)</span>
|
||||
<textarea id="create-issue-evidence-note" maxlength="240" rows="2" disabled placeholder="What does this screenshot show?"></textarea>
|
||||
</label>
|
||||
<div class="issue-evidence-review-actions" aria-label="Reorder selected screenshot">
|
||||
<button id="move-create-issue-attachment-earlier" type="button">Move earlier</button>
|
||||
<button id="edit-create-issue-attachment" type="button">Edit screenshot</button>
|
||||
<button id="remove-create-issue-attachment" type="button">Remove selected</button>
|
||||
<button id="move-create-issue-attachment-later" type="button">Move later</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||
</section>
|
||||
@@ -749,6 +818,19 @@
|
||||
</div>
|
||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<label id="create-issue-template-field" for="create-issue-template" hidden>Issue type
|
||||
<select id="create-issue-template" aria-describedby="create-issue-template-status">
|
||||
<option value="">Blank issue</option>
|
||||
</select>
|
||||
<span id="create-issue-template-status" class="small" aria-live="polite">Blank issue selected.</span>
|
||||
</label>
|
||||
<label class="create-issue-owner" for="create-issue-assignee">Owner
|
||||
<select id="create-issue-assignee" aria-describedby="create-issue-assignee-status">
|
||||
<option value="">Me</option>
|
||||
<option value="__unassigned__">No owner</option>
|
||||
</select>
|
||||
<span id="create-issue-assignee-status" class="small" aria-live="polite">Open the owner picker to load eligible teammates.</span>
|
||||
</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>
|
||||
@@ -758,6 +840,15 @@
|
||||
<label for="create-issue-due-date">Due date <span class="small">Optional</span>
|
||||
<input id="create-issue-due-date" type="date" />
|
||||
</label>
|
||||
<section class="create-issue-blockers" aria-labelledby="create-issue-blockers-heading">
|
||||
<strong id="create-issue-blockers-heading">Blocked by <span class="small">Optional · Up to 5 open issues</span></strong>
|
||||
<label for="create-issue-blocker-search">Search open issues
|
||||
<input id="create-issue-blocker-search" type="search" maxlength="80" placeholder="Issue title or key" autocomplete="off" />
|
||||
</label>
|
||||
<div id="create-issue-blocker-results" role="listbox" aria-label="Open issue blocker results" hidden></div>
|
||||
<ul id="create-issue-blocker-selected" aria-label="Selected blockers"></ul>
|
||||
<div id="create-issue-blocker-status" class="small" aria-live="polite">No blockers selected.</div>
|
||||
</section>
|
||||
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
|
||||
<legend>Labels <span class="small">Optional</span></legend>
|
||||
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
|
||||
@@ -769,6 +860,10 @@
|
||||
<div id="create-issue-duplicate-list" class="create-issue-duplicate-list"></div>
|
||||
<button id="create-issue-anyway" type="button" hidden>Create anyway</button>
|
||||
</section>
|
||||
<label class="create-issue-estimate" for="create-issue-estimate">Work estimate for Today
|
||||
<span><input id="create-issue-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" placeholder="45" aria-describedby="create-issue-estimate-status" /> min</span>
|
||||
<span id="create-issue-estimate-status" class="small" aria-live="polite">Required only for Create & start.</span>
|
||||
</label>
|
||||
<div class="small">The issue will be assigned to you.</div>
|
||||
<div class="create-issue-actions">
|
||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||
@@ -780,8 +875,66 @@
|
||||
<div id="create-issue-capture-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="issue-evidence-editor" id="issue-evidence-editor" role="dialog" aria-modal="true" aria-labelledby="issue-evidence-editor-heading" hidden>
|
||||
<div class="issue-evidence-editor-panel">
|
||||
<header>
|
||||
<div><h3 id="issue-evidence-editor-heading">Edit screenshot evidence</h3><span class="small">Edits stay on this device until you file.</span></div>
|
||||
<button id="cancel-issue-evidence-edit" type="button">Cancel</button>
|
||||
</header>
|
||||
<canvas id="issue-evidence-editor-canvas" role="img" aria-label="Screenshot editing canvas. Drag to crop, redact, highlight, or point with an arrow."></canvas>
|
||||
<canvas id="issue-evidence-editor-export" hidden></canvas>
|
||||
<div class="issue-evidence-editor-tools" role="toolbar" aria-label="Screenshot editing tools">
|
||||
<button id="crop-issue-evidence" type="button" aria-pressed="false">Crop</button>
|
||||
<button id="redact-issue-evidence" type="button" aria-pressed="true">Redact</button>
|
||||
<button id="highlight-issue-evidence" type="button" aria-pressed="false">Highlight</button>
|
||||
<button id="arrow-issue-evidence" type="button" aria-pressed="false">Arrow</button>
|
||||
<button id="undo-issue-evidence-edit" type="button">Undo</button>
|
||||
<button id="reset-issue-evidence-edit" type="button">Reset</button>
|
||||
</div>
|
||||
<div class="small" id="issue-evidence-editor-status" aria-live="polite">Drag over private information to hide it.</div>
|
||||
<button class="issue-evidence-editor-apply" id="apply-issue-evidence-edit" type="button">Apply flattened edit</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="issue-filing-review" id="issue-filing-review" role="dialog" aria-modal="true" aria-labelledby="issue-filing-review-heading" hidden>
|
||||
<div class="issue-filing-review-panel">
|
||||
<header>
|
||||
<div>
|
||||
<h2 id="issue-filing-review-heading">Review issue before filing</h2>
|
||||
<p class="small">Confirm the complete payload that will be saved for delivery.</p>
|
||||
</div>
|
||||
</header>
|
||||
<dl class="issue-filing-review-summary">
|
||||
<div><dt>Repository</dt><dd id="issue-filing-review-repository"></dd></div>
|
||||
<div><dt>Action</dt><dd id="issue-filing-review-intent"></dd></div>
|
||||
<div><dt>Issue type</dt><dd id="issue-filing-review-template"></dd></div>
|
||||
<div><dt>Title</dt><dd id="issue-filing-review-title"></dd></div>
|
||||
<div><dt>Note</dt><dd id="issue-filing-review-body"></dd></div>
|
||||
<div><dt>Planning</dt><dd id="issue-filing-review-metadata"></dd></div>
|
||||
<div><dt>Blocked by</dt><dd><ul id="issue-filing-review-blockers"></ul></dd></div>
|
||||
</dl>
|
||||
<section aria-labelledby="issue-filing-review-evidence-heading">
|
||||
<h3 id="issue-filing-review-evidence-heading">Evidence in filing order</h3>
|
||||
<p class="small muted" id="issue-filing-review-evidence-empty" hidden>No screenshots will be filed.</p>
|
||||
<ol id="issue-filing-review-evidence"></ol>
|
||||
<figure class="issue-filing-evidence-preview" id="issue-filing-review-evidence-preview" hidden>
|
||||
<img id="issue-filing-review-evidence-image" alt="" />
|
||||
<figcaption>
|
||||
<span class="small" id="issue-filing-review-evidence-position"></span>
|
||||
<strong id="issue-filing-review-evidence-filename"></strong>
|
||||
<span class="small" id="issue-filing-review-evidence-note"></span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
<div class="issue-filing-review-actions">
|
||||
<button id="back-to-issue-edit" type="button">Back to edit</button>
|
||||
<button id="confirm-issue-filing" type="button">Confirm filing</button>
|
||||
</div>
|
||||
<div class="small" id="issue-filing-review-status" aria-live="assertive"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="draft-capacity-sheet" id="draft-capacity-sheet" role="dialog" aria-modal="true" aria-labelledby="draft-capacity-heading" hidden>
|
||||
<section class="draft-capacity-panel">
|
||||
<h3 id="draft-capacity-heading">Drafts full — nothing was deleted.</h3>
|
||||
@@ -818,6 +971,7 @@
|
||||
<button class="conversation-more" id="load-older-update-comments" type="button" hidden>Load older messages</button>
|
||||
<div id="update-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<button class="update-retry" id="retry-update-conversation" type="button" hidden>Retry conversation</button>
|
||||
<button class="conversation-actions-retry" id="retry-update-comment-actions" type="button" hidden>Retry comment tools</button>
|
||||
<details>
|
||||
<summary><h2>Subject context</h2></summary>
|
||||
<div class="update-sheet-content muted markdown-content" id="update-subject-body"></div>
|
||||
@@ -892,6 +1046,7 @@
|
||||
<h2>Full conversation</h2><div id="pull-comments"></div>
|
||||
<button class="conversation-more" id="load-older-pull-comments" type="button" hidden>Load older messages</button>
|
||||
<div id="pull-conversation-status" class="small" aria-live="assertive"></div>
|
||||
<button class="conversation-actions-retry" id="retry-pull-comment-actions" type="button" hidden>Retry comment tools</button>
|
||||
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
|
||||
<h2 id="pull-comment-title">Add comment</h2>
|
||||
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||
@@ -1059,6 +1214,7 @@
|
||||
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
|
||||
@@ -1079,14 +1235,17 @@
|
||||
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/feature-loader.js"></script>
|
||||
<script src="static/conversation-action-hydrator.js"></script>
|
||||
<script src="static/security-center.js"></script>
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/saved-searches.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/search-defer.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/unfiled-captures.js"></script>
|
||||
<script src="static/unfiled-draft-sync.js"></script>
|
||||
<script src="static/shared-image-capture.js"></script>
|
||||
<script src="static/draft-filing-session.js"></script>
|
||||
<script src="static/draft-capacity-dialog.js"></script>
|
||||
@@ -1129,7 +1288,10 @@
|
||||
<script src="static/search-batch-plan.js"></script>
|
||||
<script src="static/conversation.js"></script>
|
||||
<script src="static/comment-actions.js"></script>
|
||||
<script src="static/issue-evidence-review.js"></script>
|
||||
<script src="static/issue-evidence-editor.js"></script>
|
||||
<script src="static/issue-attachment.js"></script>
|
||||
<script src="static/issue-filing-review.js"></script>
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
@@ -1160,6 +1322,7 @@
|
||||
<script src="static/mobile-composer-viewport.js"></script>
|
||||
<script src="static/mention-composer.js"></script>
|
||||
<script src="static/push-notifications.js"></script>
|
||||
<script src="static/issue-filing-receipt.js"></script>
|
||||
<script src="static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+235
-111
@@ -1,55 +1,28 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
const review = typeof module === 'object' && module.exports ? require('./issue-evidence-review.js') : root.issueEvidenceReview;
|
||||
const editor = typeof module === 'object' && module.exports ? require('./issue-evidence-editor.js') : root.issueEvidenceEditor;
|
||||
const api = factory(review, editor);
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.issueAttachment = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
})(typeof self !== 'undefined' ? self : this, function(issueEvidenceReview, issueEvidenceEditor) {
|
||||
'use strict';
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_PIXELS = issueEvidenceReview.MAX_PIXELS;
|
||||
const MAX_FILES = 5;
|
||||
const MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.';
|
||||
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
function namedBlob(blob, name) {
|
||||
if (typeof File === 'function') {
|
||||
return new File([blob], name, { type: blob.type });
|
||||
}
|
||||
Object.defineProperty(blob, 'name', { value: name, configurable: true });
|
||||
return blob;
|
||||
function normalizeNote(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
}
|
||||
|
||||
async function optimizeImage(file, environment = {}) {
|
||||
if (file.size <= MAX_BYTES) return file;
|
||||
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
|
||||
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
|
||||
if (typeof decode !== 'function') {
|
||||
throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.');
|
||||
}
|
||||
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await decode(file);
|
||||
const canvas = makeCanvas();
|
||||
const context = canvas && canvas.getContext && canvas.getContext('2d');
|
||||
if (!context || !bitmap.width || !bitmap.height) throw new Error('decode');
|
||||
let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92);
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise(resolve => {
|
||||
canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86);
|
||||
});
|
||||
if (!blob) throw new Error('encode');
|
||||
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
|
||||
scale *= 0.8;
|
||||
}
|
||||
} catch (_error) {
|
||||
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
|
||||
} finally {
|
||||
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
|
||||
}
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
function escapeMarkdown(value) {
|
||||
return value.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
|
||||
}
|
||||
|
||||
const optimizeImage = issueEvidenceReview.optimizeImage;
|
||||
|
||||
function multipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
@@ -65,6 +38,8 @@
|
||||
|
||||
function create(options) {
|
||||
const upload = options.upload;
|
||||
const maxFiles = options.maxFiles === MAX_FILES ? MAX_FILES : 1;
|
||||
const inspectPixels = options.inspectPixels === true;
|
||||
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
|
||||
const createOperationId = options.createOperationId || (() => {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
|
||||
@@ -72,10 +47,7 @@
|
||||
}
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
});
|
||||
let selected = null;
|
||||
let confirmed = null;
|
||||
let serialized = null;
|
||||
let operationId = null;
|
||||
let selected = [];
|
||||
let selectionGeneration = 0;
|
||||
|
||||
function commitSelection(file) {
|
||||
@@ -83,14 +55,18 @@
|
||||
file.size <= 0 || file.size > MAX_BYTES) {
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
}
|
||||
selected = file;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
operationId = createOperationId();
|
||||
if (selected.length >= maxFiles) {
|
||||
if (maxFiles === 1) selected = [];
|
||||
else throw new Error(MAX_FILES_MESSAGE);
|
||||
}
|
||||
selected.push({file, note:'', confirmed:null, serialized:null, operationId:createOperationId()});
|
||||
return state();
|
||||
}
|
||||
|
||||
function select(file) {
|
||||
if (selected.length >= maxFiles && maxFiles > 1) {
|
||||
throw new Error(MAX_FILES_MESSAGE);
|
||||
}
|
||||
const generation = ++selectionGeneration;
|
||||
if (!file || !IMAGE_TYPES.has(file.type)) {
|
||||
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
|
||||
@@ -98,7 +74,7 @@
|
||||
if (!Number.isFinite(file.size) || file.size <= 0) {
|
||||
throw new Error('Choose a screenshot that is 2 MB or smaller.');
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
if (file.size > MAX_BYTES || inspectPixels) {
|
||||
return Promise.resolve(optimizeSelectedImage(file)).then(optimized => {
|
||||
if (generation !== selectionGeneration) return state();
|
||||
return commitSelection(optimized);
|
||||
@@ -109,87 +85,167 @@
|
||||
|
||||
function clear() {
|
||||
selectionGeneration += 1;
|
||||
selected = null;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
operationId = null;
|
||||
selected = [];
|
||||
}
|
||||
|
||||
function remove(index) {
|
||||
const position = Number(index);
|
||||
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
|
||||
selectionGeneration += 1;
|
||||
selected.splice(position, 1);
|
||||
return state();
|
||||
}
|
||||
|
||||
function move(fromIndex, toIndex) {
|
||||
const from = Number(fromIndex);
|
||||
const to = Number(toIndex);
|
||||
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 ||
|
||||
from >= selected.length || to < 0 || to >= selected.length || from === to) return state();
|
||||
const [item] = selected.splice(from, 1);
|
||||
selected.splice(to, 0, item);
|
||||
return state();
|
||||
}
|
||||
|
||||
function replace(index, file) {
|
||||
const position = Number(index);
|
||||
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
|
||||
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
|
||||
file.size <= 0 || file.size > MAX_BYTES) {
|
||||
throw new Error('The edited screenshot must be a PNG, JPEG, or WebP image no larger than 2 MB.');
|
||||
}
|
||||
selectionGeneration += 1;
|
||||
selected[position] = {
|
||||
...selected[position], file, confirmed:null, serialized:null, operationId:createOperationId(),
|
||||
};
|
||||
return state();
|
||||
}
|
||||
|
||||
function setNote(index, value) {
|
||||
const position = Number(index);
|
||||
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
|
||||
selected[position].note = normalizeNote(value);
|
||||
selected[position].serialized = null;
|
||||
return state();
|
||||
}
|
||||
|
||||
function note(index) {
|
||||
const position = Number(index);
|
||||
return Number.isInteger(position) && selected[position] ? selected[position].note : '';
|
||||
}
|
||||
|
||||
function restore(value) {
|
||||
if (Array.isArray(value)) {
|
||||
clear();
|
||||
if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE);
|
||||
value.forEach(attachment => restoreOne(attachment));
|
||||
return state();
|
||||
}
|
||||
clear();
|
||||
restoreOne(value);
|
||||
return state();
|
||||
}
|
||||
|
||||
function restoreOne(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '');
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!blob && !data) {
|
||||
clear();
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
const padding = (data.match(/=*$/) || [''])[0].length;
|
||||
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
||||
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
|
||||
serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
|
||||
return state();
|
||||
commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
|
||||
const item = selected[selected.length - 1];
|
||||
item.note = normalizeNote(value?.note);
|
||||
item.serialized = {
|
||||
...(blob ? { filename, contentType, blob } : { filename, contentType, data }),
|
||||
...(item.note ? {note:item.note} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function state() {
|
||||
return selected ? {
|
||||
name: selected.name,
|
||||
size: selected.size,
|
||||
uploaded: Boolean(confirmed),
|
||||
} : null;
|
||||
const values = selected.map(item => ({
|
||||
name: item.file.name,
|
||||
size: item.file.size,
|
||||
uploaded: Boolean(item.confirmed),
|
||||
}));
|
||||
return values.length > 1 ? values : (values[0] || null);
|
||||
}
|
||||
|
||||
function serializeItem(item) {
|
||||
if (!item.serialized) item.serialized = {
|
||||
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
|
||||
...(item.note ? {note:item.note} : {}),
|
||||
};
|
||||
return { ...item.serialized };
|
||||
}
|
||||
|
||||
async function serialize() {
|
||||
if (!selected) return null;
|
||||
if (!serialized) {
|
||||
serialized = {
|
||||
filename: selected.name,
|
||||
contentType: selected.type,
|
||||
blob: selected.blob || selected,
|
||||
};
|
||||
}
|
||||
return { ...serialized };
|
||||
if (!selected.length) return null;
|
||||
const values = selected.map(serializeItem);
|
||||
return values.length > 1 ? values : values[0];
|
||||
}
|
||||
|
||||
async function prepareComment(item, body) {
|
||||
async function prepareComment(target, body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!selected) return text;
|
||||
if (!confirmed) {
|
||||
const attachment = await serialize();
|
||||
confirmed = await upload({
|
||||
repository: item.repository,
|
||||
number: item.number,
|
||||
filename: attachment.filename,
|
||||
content_type: attachment.contentType,
|
||||
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
|
||||
operation_id: operationId,
|
||||
});
|
||||
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
|
||||
confirmed = null;
|
||||
throw new Error('The server did not confirm the screenshot upload.');
|
||||
if (!selected.length) return text;
|
||||
const markdown = [];
|
||||
for (const evidence of selected) {
|
||||
if (!evidence.confirmed) {
|
||||
const attachment = serializeItem(evidence);
|
||||
evidence.confirmed = await upload({
|
||||
repository: target.repository,
|
||||
number: target.number,
|
||||
filename: attachment.filename,
|
||||
content_type: attachment.contentType,
|
||||
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
|
||||
operation_id: evidence.operationId,
|
||||
});
|
||||
if (!evidence.confirmed || typeof evidence.confirmed.markdown !== 'string' || !evidence.confirmed.markdown) {
|
||||
evidence.confirmed = null;
|
||||
throw new Error('The server did not confirm the screenshot upload.');
|
||||
}
|
||||
}
|
||||
if (evidence.note) {
|
||||
markdown.push('**Screenshot ' + (markdown.length + 1) + ' — ' + escapeMarkdown(evidence.note) + '**\n\n' +
|
||||
evidence.confirmed.markdown);
|
||||
} else markdown.push(evidence.confirmed.markdown);
|
||||
}
|
||||
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
|
||||
const evidence = markdown.join('\n\n');
|
||||
return text ? text + '\n\n' + evidence : evidence;
|
||||
}
|
||||
|
||||
return { select, restore, clear, state, serialize, prepareComment };
|
||||
return { select, restore, remove, move, replace, setNote, note, clear, state, serialize, prepareComment };
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
const controller = create(options);
|
||||
const controller = create({
|
||||
...options, inspectPixels:true,
|
||||
maxFiles:options.maxFiles || (options.input?.multiple ? MAX_FILES : 1),
|
||||
});
|
||||
const clearSelection = controller.clear;
|
||||
const restoreSelection = controller.restore;
|
||||
const reviewEnabled = Boolean(options.tray && options.earlier && options.later);
|
||||
let previewUrl = '';
|
||||
let selectionSequence = 0;
|
||||
const review = reviewEnabled ? issueEvidenceReview.create({
|
||||
...options, edit:options.editor?.edit, controller,
|
||||
}) : null;
|
||||
|
||||
function setBusy(busy) {
|
||||
options.input.disabled = Boolean(busy);
|
||||
options.remove.disabled = Boolean(busy);
|
||||
function setBusy(value) {
|
||||
options.input.disabled = Boolean(value);
|
||||
options.remove.disabled = Boolean(value);
|
||||
if (options.editor?.edit) options.editor.edit.disabled = Boolean(value) || !controller.state();
|
||||
review?.setBusy(value);
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
selectionSequence += 1;
|
||||
if (previewUrl) options.revokeObjectURL(previewUrl);
|
||||
previewUrl = '';
|
||||
review?.clear();
|
||||
if (options.editor?.edit) options.editor.edit.disabled = true;
|
||||
options.image.src = '';
|
||||
options.preview.hidden = true;
|
||||
options.input.value = '';
|
||||
@@ -203,29 +259,53 @@
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
if (options.editor?.edit) options.editor.edit.disabled = false;
|
||||
options.status.textContent = optimized ? 'Screenshot optimized and ready to upload.' :
|
||||
(options.readyMessage || 'Screenshot ready to upload with this comment.');
|
||||
}
|
||||
|
||||
options.input.addEventListener('change', event => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
const files = Array.from(event.target.files || []);
|
||||
const sequence = ++selectionSequence;
|
||||
let result;
|
||||
try {
|
||||
result = controller.select(file);
|
||||
} catch (error) {
|
||||
if (!files.length) return;
|
||||
let first;
|
||||
try { first = controller.select(files[0]); }
|
||||
catch (error) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
return;
|
||||
}
|
||||
if (!result || typeof result.then !== 'function') {
|
||||
showPreview(file, false);
|
||||
if (!reviewEnabled && files.length === 1 && (!first || typeof first.then !== 'function')) {
|
||||
showPreview(files[0], false);
|
||||
return;
|
||||
}
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
options.input.disabled = true;
|
||||
return result.then(() => controller.serialize()).then(value => {
|
||||
if (sequence === selectionSequence) showPreview(value.blob, true);
|
||||
let optimized = Boolean(first && typeof first.then === 'function');
|
||||
if (optimized) {
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
options.input.disabled = true;
|
||||
}
|
||||
const selectAll = files.slice(1).reduce((pending, file) => pending.then(async () => {
|
||||
const result = controller.select(file);
|
||||
if (result && typeof result.then === 'function') {
|
||||
optimized = true;
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
options.input.disabled = true;
|
||||
await result;
|
||||
}
|
||||
}), Promise.resolve(first));
|
||||
return selectAll.then(() => controller.serialize()).then(value => {
|
||||
if (sequence !== selectionSequence) return;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
const latest = values[values.length - 1];
|
||||
if (reviewEnabled) {
|
||||
review.render(values, values.length - 1, optimized);
|
||||
} else {
|
||||
showPreview(latest.blob, optimized);
|
||||
if (values.length > 1) {
|
||||
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
|
||||
options.status.textContent = values.length + ' screenshots ready to file in this order.';
|
||||
}
|
||||
}
|
||||
}).catch(error => {
|
||||
if (sequence === selectionSequence) {
|
||||
options.status.textContent = error.message;
|
||||
@@ -236,24 +316,68 @@
|
||||
});
|
||||
});
|
||||
options.remove.addEventListener('click', () => {
|
||||
clearPreview();
|
||||
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
|
||||
const current = controller.state();
|
||||
const count = Array.isArray(current) ? current.length : (current ? 1 : 0);
|
||||
if (count <= 1) {
|
||||
clearPreview();
|
||||
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
|
||||
return Promise.resolve();
|
||||
}
|
||||
const removedIndex = reviewEnabled ? review.activeIndex() : count - 1;
|
||||
controller.remove(removedIndex);
|
||||
selectionSequence += 1;
|
||||
review?.afterRemoval(removedIndex, count);
|
||||
return controller.serialize().then(value => {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
if (reviewEnabled) {
|
||||
review.render(values, review.activeIndex());
|
||||
} else {
|
||||
const latest = values[values.length - 1];
|
||||
showPreview(latest.blob, false);
|
||||
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
|
||||
}
|
||||
options.status.textContent = options.removedMessage || 'Latest screenshot removed. Your text is unchanged.';
|
||||
});
|
||||
});
|
||||
|
||||
function restorePreview(value) {
|
||||
clearPreview();
|
||||
const restored = controller.restore(value);
|
||||
previewUrl = value.blob ? options.createObjectURL(value.blob) :
|
||||
'data:' + value.contentType + ';base64,' + value.data;
|
||||
const restored = restoreSelection(value);
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
if (reviewEnabled) {
|
||||
review.render(values, values.length - 1);
|
||||
return restored;
|
||||
}
|
||||
const latest = values[values.length - 1];
|
||||
previewUrl = latest.blob ? options.createObjectURL(latest.blob) :
|
||||
'data:' + latest.contentType + ';base64,' + latest.data;
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
|
||||
options.meta.textContent = values.length > 1 ?
|
||||
values.length + ' screenshots ready · latest: ' + latest.filename :
|
||||
restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
||||
options.status.textContent = values.length > 1 ?
|
||||
values.length + ' screenshots ready to file in this order.' :
|
||||
(options.readyMessage || 'Screenshot ready to upload with this comment.');
|
||||
return restored;
|
||||
}
|
||||
|
||||
if (review && options.editor && issueEvidenceEditor) {
|
||||
issueEvidenceEditor.mount({
|
||||
...options.editor,
|
||||
controller,
|
||||
getActiveIndex:review.activeIndex,
|
||||
optimizeImage,
|
||||
onApplied: async index => {
|
||||
const value = await controller.serialize();
|
||||
review.render(value, index);
|
||||
options.status.textContent = 'Edited screenshot flattened and ready to file.';
|
||||
},
|
||||
});
|
||||
options.editor.edit.disabled = !controller.state();
|
||||
}
|
||||
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
|
||||
}
|
||||
|
||||
return { create, mount, multipart, optimizeImage, MAX_BYTES };
|
||||
return { create, mount, multipart, optimizeImage, MAX_BYTES, MAX_PIXELS };
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.issueEvidenceEditor = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
function boundedRect(value, bounds) {
|
||||
const x = Math.max(bounds.x, Math.min(bounds.x + bounds.width - 1, Math.round(Number(value.x) || 0)));
|
||||
const y = Math.max(bounds.y, Math.min(bounds.y + bounds.height - 1, Math.round(Number(value.y) || 0)));
|
||||
const width = Math.max(1, Math.min(bounds.x + bounds.width - x, Math.round(Number(value.width) || 0)));
|
||||
const height = Math.max(1, Math.min(bounds.y + bounds.height - y, Math.round(Number(value.height) || 0)));
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function boundedPoint(value, bounds) {
|
||||
return {
|
||||
x: Math.max(bounds.x, Math.min(bounds.x + bounds.width, Math.round(Number(value.x) || 0))),
|
||||
y: Math.max(bounds.y, Math.min(bounds.y + bounds.height, Math.round(Number(value.y) || 0))),
|
||||
};
|
||||
}
|
||||
|
||||
function createModel(size) {
|
||||
const full = { x:0, y:0, width:Math.max(1, Math.round(size.width)), height:Math.max(1, Math.round(size.height)) };
|
||||
let crop = { ...full };
|
||||
let redactions = [];
|
||||
let annotations = [];
|
||||
let history = [];
|
||||
|
||||
function remember() {
|
||||
history.push({
|
||||
crop:{...crop}, redactions:redactions.map(value => ({...value})),
|
||||
annotations:annotations.map(value => ({...value})),
|
||||
});
|
||||
}
|
||||
function intersects(annotation, bounds) {
|
||||
const left = annotation.type === 'arrow' ? Math.min(annotation.startX, annotation.endX) : annotation.x;
|
||||
const top = annotation.type === 'arrow' ? Math.min(annotation.startY, annotation.endY) : annotation.y;
|
||||
const right = annotation.type === 'arrow' ? Math.max(annotation.startX, annotation.endX) : annotation.x + annotation.width;
|
||||
const bottom = annotation.type === 'arrow' ? Math.max(annotation.startY, annotation.endY) : annotation.y + annotation.height;
|
||||
return left < bounds.x + bounds.width && top < bounds.y + bounds.height && right > bounds.x && bottom > bounds.y;
|
||||
}
|
||||
function clampAnnotation(annotation) {
|
||||
if (annotation.type === 'highlight') {
|
||||
const x = Math.max(crop.x, annotation.x);
|
||||
const y = Math.max(crop.y, annotation.y);
|
||||
const right = Math.min(crop.x + crop.width, annotation.x + annotation.width);
|
||||
const bottom = Math.min(crop.y + crop.height, annotation.y + annotation.height);
|
||||
return { type:'highlight', x, y, width:Math.max(1, right - x), height:Math.max(1, bottom - y) };
|
||||
}
|
||||
const start = boundedPoint({x:annotation.startX, y:annotation.startY}, crop);
|
||||
const end = boundedPoint({x:annotation.endX, y:annotation.endY}, crop);
|
||||
return { type:'arrow', startX:start.x, startY:start.y, endX:end.x, endY:end.y };
|
||||
}
|
||||
function setCrop(value) {
|
||||
remember();
|
||||
crop = boundedRect(value, crop);
|
||||
redactions = redactions.filter(rectangle =>
|
||||
rectangle.x < crop.x + crop.width && rectangle.y < crop.y + crop.height &&
|
||||
rectangle.x + rectangle.width > crop.x && rectangle.y + rectangle.height > crop.y
|
||||
).map(rectangle => boundedRect(rectangle, crop));
|
||||
annotations = annotations.filter(annotation => intersects(annotation, crop)).map(clampAnnotation);
|
||||
return snapshot();
|
||||
}
|
||||
function addRedaction(value) {
|
||||
remember();
|
||||
redactions.push(boundedRect(value, crop));
|
||||
return snapshot();
|
||||
}
|
||||
function addHighlight(value) {
|
||||
remember();
|
||||
annotations.push({ type:'highlight', ...boundedRect(value, crop) });
|
||||
return snapshot();
|
||||
}
|
||||
function addArrow(value) {
|
||||
remember();
|
||||
const start = boundedPoint({x:value.startX, y:value.startY}, crop);
|
||||
const end = boundedPoint({x:value.endX, y:value.endY}, crop);
|
||||
annotations.push({ type:'arrow', startX:start.x, startY:start.y, endX:end.x, endY:end.y });
|
||||
return snapshot();
|
||||
}
|
||||
function undo() {
|
||||
const previous = history.pop();
|
||||
if (previous) {
|
||||
crop = previous.crop;
|
||||
redactions = previous.redactions;
|
||||
annotations = previous.annotations;
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
function reset() {
|
||||
remember();
|
||||
crop = { ...full };
|
||||
redactions = [];
|
||||
annotations = [];
|
||||
return snapshot();
|
||||
}
|
||||
function snapshot() {
|
||||
return {
|
||||
crop:{...crop}, redactions:redactions.map(value => ({...value})),
|
||||
annotations:annotations.map(value => ({...value})),
|
||||
};
|
||||
}
|
||||
return { addArrow, addHighlight, addRedaction, reset, setCrop, snapshot, undo };
|
||||
}
|
||||
|
||||
function namedBlob(blob, name) {
|
||||
if (typeof File === 'function') return new File([blob], name, {type:blob.type});
|
||||
Object.defineProperty(blob, 'name', {value:name, configurable:true});
|
||||
return blob;
|
||||
}
|
||||
|
||||
function paint(options) {
|
||||
const state = options.model.snapshot();
|
||||
const maxDimension = Number.isFinite(options.maxDimension) ? options.maxDimension : Infinity;
|
||||
const scale = Math.min(1, maxDimension / Math.max(state.crop.width, state.crop.height));
|
||||
const width = Math.max(1, Math.round(state.crop.width * scale));
|
||||
const height = Math.max(1, Math.round(state.crop.height * scale));
|
||||
options.canvas.width = width;
|
||||
options.canvas.height = height;
|
||||
const context = options.canvas.getContext('2d');
|
||||
if (!context) throw new Error('Screenshot editing is unavailable in this browser.');
|
||||
context.drawImage(options.source, state.crop.x, state.crop.y, state.crop.width, state.crop.height, 0, 0, width, height);
|
||||
context.fillStyle = '#000000';
|
||||
state.redactions.forEach(rectangle => context.fillRect(
|
||||
Math.round((rectangle.x - state.crop.x) * scale),
|
||||
Math.round((rectangle.y - state.crop.y) * scale),
|
||||
Math.round(rectangle.width * scale),
|
||||
Math.round(rectangle.height * scale),
|
||||
));
|
||||
state.annotations.forEach(annotation => {
|
||||
if (annotation.type === 'highlight') {
|
||||
context.fillStyle = 'rgba(250, 204, 21, 0.38)';
|
||||
context.fillRect(
|
||||
Math.round((annotation.x - state.crop.x) * scale),
|
||||
Math.round((annotation.y - state.crop.y) * scale),
|
||||
Math.round(annotation.width * scale),
|
||||
Math.round(annotation.height * scale),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const startX = (annotation.startX - state.crop.x) * scale;
|
||||
const startY = (annotation.startY - state.crop.y) * scale;
|
||||
const endX = (annotation.endX - state.crop.x) * scale;
|
||||
const endY = (annotation.endY - state.crop.y) * scale;
|
||||
const angle = Math.atan2(endY - startY, endX - startX);
|
||||
const head = Math.max(10, Math.min(24, 16 * scale));
|
||||
context.save();
|
||||
context.strokeStyle = '#facc15';
|
||||
context.lineWidth = Math.max(4, 6 * scale);
|
||||
context.lineCap = 'round';
|
||||
context.lineJoin = 'round';
|
||||
context.beginPath();
|
||||
context.moveTo(Math.round(startX), Math.round(startY));
|
||||
context.lineTo(Math.round(endX), Math.round(endY));
|
||||
context.lineTo(endX - head * Math.cos(angle - Math.PI / 6), endY - head * Math.sin(angle - Math.PI / 6));
|
||||
context.moveTo(Math.round(endX), Math.round(endY));
|
||||
context.lineTo(endX - head * Math.cos(angle + Math.PI / 6), endY - head * Math.sin(angle + Math.PI / 6));
|
||||
context.stroke();
|
||||
context.restore();
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
async function flatten(options) {
|
||||
paint(options);
|
||||
const type = ['image/png', 'image/jpeg', 'image/webp'].includes(options.type) ? options.type : 'image/png';
|
||||
const blob = await new Promise(resolve => options.canvas.toBlob(resolve, type, type === 'image/png' ? undefined : 0.9));
|
||||
if (!blob || !blob.size) throw new Error('The edited screenshot could not be saved. Try again.');
|
||||
return namedBlob(blob, options.name || 'edited-screenshot.png');
|
||||
}
|
||||
|
||||
function attachmentBlob(value) {
|
||||
if (value.blob) return value.blob;
|
||||
const binary = atob(String(value.data || ''));
|
||||
return new Blob([Uint8Array.from(binary, character => character.charCodeAt(0))], {type:value.contentType});
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
let source = null;
|
||||
let model = null;
|
||||
let mode = 'redact';
|
||||
let start = null;
|
||||
let selectedIndex = -1;
|
||||
let previousFocus = null;
|
||||
|
||||
function status(message) { options.status.textContent = message; }
|
||||
function setMode(value) {
|
||||
mode = value;
|
||||
options.crop.setAttribute('aria-pressed', value === 'crop' ? 'true' : 'false');
|
||||
options.redact.setAttribute('aria-pressed', value === 'redact' ? 'true' : 'false');
|
||||
options.highlight.setAttribute('aria-pressed', value === 'highlight' ? 'true' : 'false');
|
||||
options.arrow.setAttribute('aria-pressed', value === 'arrow' ? 'true' : 'false');
|
||||
status({
|
||||
crop:'Drag around the part to keep.', redact:'Drag over private information to hide it.',
|
||||
highlight:'Drag around the detail to highlight.', arrow:'Drag toward the detail you want to point out.',
|
||||
}[value]);
|
||||
}
|
||||
function render() {
|
||||
if (source && model) paint({source, model, canvas:options.canvas, maxDimension:1600});
|
||||
}
|
||||
function close() {
|
||||
options.dialog.hidden = true;
|
||||
if (source && typeof source.close === 'function') source.close();
|
||||
source = null;
|
||||
model = null;
|
||||
start = null;
|
||||
(previousFocus || options.edit).focus();
|
||||
}
|
||||
async function open() {
|
||||
const value = await options.controller.serialize();
|
||||
const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
|
||||
selectedIndex = options.getActiveIndex();
|
||||
const selected = values[selectedIndex];
|
||||
if (!selected) return;
|
||||
previousFocus = options.document.activeElement;
|
||||
status('Opening screenshot editor…');
|
||||
try {
|
||||
const decode = options.decode || globalThis.createImageBitmap;
|
||||
if (typeof decode !== 'function') throw new Error('decode');
|
||||
source = await decode(attachmentBlob(selected));
|
||||
model = createModel({width:source.width, height:source.height});
|
||||
options.dialog.hidden = false;
|
||||
render();
|
||||
setMode('redact');
|
||||
options.redact.focus();
|
||||
} catch (_error) {
|
||||
source = null;
|
||||
status('This browser cannot edit the screenshot. Your original is unchanged.');
|
||||
}
|
||||
}
|
||||
function point(event) {
|
||||
const box = options.canvas.getBoundingClientRect();
|
||||
const state = model.snapshot();
|
||||
return {
|
||||
x:state.crop.x + Math.round((event.clientX - box.left) * state.crop.width / box.width),
|
||||
y:state.crop.y + Math.round((event.clientY - box.top) * state.crop.height / box.height),
|
||||
};
|
||||
}
|
||||
options.canvas.addEventListener('pointerdown', event => {
|
||||
if (!model) return;
|
||||
start = point(event);
|
||||
options.canvas.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
options.canvas.addEventListener('pointerup', event => {
|
||||
if (!model || !start) return;
|
||||
const end = point(event);
|
||||
const dragStart = start;
|
||||
const rectangle = {
|
||||
x:Math.min(dragStart.x, end.x), y:Math.min(dragStart.y, end.y),
|
||||
width:Math.abs(end.x - dragStart.x), height:Math.abs(end.y - dragStart.y),
|
||||
};
|
||||
start = null;
|
||||
const tooSmall = mode === 'arrow' ? Math.hypot(end.x - dragStart.x, end.y - dragStart.y) < 8 :
|
||||
rectangle.width < 3 || rectangle.height < 3;
|
||||
if (tooSmall) {
|
||||
status('Drag a larger area on the screenshot.');
|
||||
return;
|
||||
}
|
||||
if (mode === 'crop') model.setCrop(rectangle);
|
||||
else if (mode === 'redact') model.addRedaction(rectangle);
|
||||
else if (mode === 'highlight') model.addHighlight(rectangle);
|
||||
else model.addArrow({startX:dragStart.x, startY:dragStart.y, endX:end.x, endY:end.y});
|
||||
render();
|
||||
status({
|
||||
crop:'Crop applied. Undo or reset if needed.', redact:'Private area hidden with an opaque redaction.',
|
||||
highlight:'Highlight added.', arrow:'Arrow added.',
|
||||
}[mode]);
|
||||
});
|
||||
options.edit.addEventListener('click', open);
|
||||
options.crop.addEventListener('click', () => setMode('crop'));
|
||||
options.redact.addEventListener('click', () => setMode('redact'));
|
||||
options.highlight.addEventListener('click', () => setMode('highlight'));
|
||||
options.arrow.addEventListener('click', () => setMode('arrow'));
|
||||
options.undo.addEventListener('click', () => { if (model) { model.undo(); render(); status('Last edit undone.'); } });
|
||||
options.reset.addEventListener('click', () => { if (model) { model.reset(); render(); status('Crop, redactions, and annotations reset.'); } });
|
||||
options.cancel.addEventListener('click', close);
|
||||
options.dialog.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); close(); } });
|
||||
options.apply.addEventListener('click', async () => {
|
||||
if (!model || !source) return;
|
||||
options.apply.disabled = true;
|
||||
status('Applying flattened edit…');
|
||||
try {
|
||||
const current = await options.controller.serialize();
|
||||
const values = Array.isArray(current) ? current : [current];
|
||||
const selected = values[selectedIndex];
|
||||
let derivative = await flatten({
|
||||
source, model, canvas:options.exportCanvas, maxDimension:2048,
|
||||
name:selected.filename, type:selected.contentType,
|
||||
});
|
||||
if (options.optimizeImage) derivative = await options.optimizeImage(derivative);
|
||||
options.controller.replace(selectedIndex, derivative);
|
||||
await options.onApplied(selectedIndex);
|
||||
close();
|
||||
} catch (error) {
|
||||
status(error.message || 'The edited screenshot could not be applied. Your original is unchanged.');
|
||||
} finally {
|
||||
options.apply.disabled = false;
|
||||
}
|
||||
});
|
||||
return { close, open };
|
||||
}
|
||||
|
||||
return { createModel, flatten, mount, paint };
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.issueEvidenceReview = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_PIXELS = 12 * 1024 * 1024;
|
||||
const MAX_DIMENSION = 8192;
|
||||
|
||||
function namedBlob(blob, name) {
|
||||
if (typeof File === 'function') return new File([blob], name, { type: blob.type });
|
||||
Object.defineProperty(blob, 'name', { value: name, configurable: true });
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function optimizeImage(file, environment = {}) {
|
||||
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
|
||||
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
|
||||
if (typeof decode !== 'function') throw new Error('This browser cannot safely inspect the screenshot. Try cropping it and choose it again.');
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await decode(file);
|
||||
const width = Number(bitmap.width);
|
||||
const height = Number(bitmap.height);
|
||||
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
|
||||
throw new Error('decode');
|
||||
}
|
||||
const pixelScale = Math.min(1, Math.sqrt(MAX_PIXELS / (width * height)));
|
||||
const dimensionScale = Math.min(1, MAX_DIMENSION / Math.max(width, height));
|
||||
const byteScale = file.size > MAX_BYTES ? Math.sqrt(MAX_BYTES / file.size) * 0.92 : 1;
|
||||
let scale = Math.min(pixelScale, dimensionScale, byteScale);
|
||||
if (scale === 1 && file.size <= MAX_BYTES) return file;
|
||||
const canvas = makeCanvas();
|
||||
const context = canvas && canvas.getContext && canvas.getContext('2d');
|
||||
if (!context) throw new Error('decode');
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
canvas.width = Math.max(1, Math.floor(width * scale));
|
||||
canvas.height = Math.max(1, Math.floor(height * scale));
|
||||
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86));
|
||||
if (!blob) throw new Error('encode');
|
||||
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
|
||||
scale *= 0.8;
|
||||
}
|
||||
} catch (_error) {
|
||||
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
|
||||
} finally {
|
||||
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
|
||||
}
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const controller = options.controller;
|
||||
const documentRef = options.document || document;
|
||||
let activeIndex = 0;
|
||||
let busy = false;
|
||||
let previewUrl = '';
|
||||
let thumbnailUrls = [];
|
||||
|
||||
function revoke(url) {
|
||||
if (url && !url.startsWith('data:')) options.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function attachmentUrl(attachment) {
|
||||
return attachment?.blob ? options.createObjectURL(attachment.blob) :
|
||||
'data:' + attachment.contentType + ';base64,' + attachment.data;
|
||||
}
|
||||
|
||||
function count() {
|
||||
const value = controller.state();
|
||||
return Array.isArray(value) ? value.length : (value ? 1 : 0);
|
||||
}
|
||||
|
||||
function setBusy(value) {
|
||||
busy = Boolean(value);
|
||||
options.earlier.disabled = busy || activeIndex <= 0;
|
||||
options.later.disabled = busy || activeIndex >= count() - 1;
|
||||
if (options.note) options.note.disabled = busy;
|
||||
if (options.edit) options.edit.disabled = busy || !count();
|
||||
Array.from(options.tray.children || []).forEach(button => { button.disabled = busy; });
|
||||
}
|
||||
|
||||
function updateNote(values) {
|
||||
if (!options.note) return;
|
||||
options.note.value = controller.note(activeIndex);
|
||||
options.note.disabled = busy;
|
||||
if (options.noteLabel) {
|
||||
options.noteLabel.textContent = 'Evidence note for screenshot ' + (activeIndex + 1) + ' of ' +
|
||||
values.length + ' (optional)';
|
||||
}
|
||||
}
|
||||
|
||||
function update(values, optimized = false) {
|
||||
if (!values.length) return;
|
||||
activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1));
|
||||
revoke(previewUrl);
|
||||
previewUrl = attachmentUrl(values[activeIndex]);
|
||||
options.image.src = previewUrl;
|
||||
const current = values[activeIndex];
|
||||
const size = current.blob ? current.blob.size : Math.max(1, Math.floor(String(current.data || '').length * 3 / 4));
|
||||
options.meta.textContent = 'Screenshot ' + (activeIndex + 1) + ' of ' + values.length + ' · ' +
|
||||
current.filename + ' · ' + Math.ceil(size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
if (options.edit) options.edit.disabled = busy;
|
||||
Array.from(options.tray.children || []).forEach((button, index) =>
|
||||
button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false'));
|
||||
options.earlier.disabled = busy || activeIndex === 0;
|
||||
options.later.disabled = busy || activeIndex === values.length - 1;
|
||||
updateNote(values);
|
||||
options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' :
|
||||
values.length + ' screenshots ready to file in this order.';
|
||||
}
|
||||
|
||||
function render(value, index = activeIndex, optimized = false) {
|
||||
const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
|
||||
activeIndex = Math.max(0, Math.min(index, values.length - 1));
|
||||
thumbnailUrls.forEach(revoke);
|
||||
thumbnailUrls = [];
|
||||
options.tray.replaceChildren(...values.map((item, itemIndex) => {
|
||||
const button = documentRef.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'issue-evidence-thumbnail';
|
||||
button.setAttribute('aria-label', 'Review screenshot ' + (itemIndex + 1) + ' of ' + values.length + ': ' + item.filename);
|
||||
const thumbnail = documentRef.createElement('img');
|
||||
thumbnail.alt = '';
|
||||
thumbnail.src = attachmentUrl(item);
|
||||
thumbnailUrls.push(thumbnail.src);
|
||||
button.appendChild(thumbnail);
|
||||
const position = documentRef.createElement('span');
|
||||
position.textContent = String(itemIndex + 1);
|
||||
button.appendChild(position);
|
||||
button.addEventListener('click', () => { activeIndex = itemIndex; update(values); });
|
||||
return button;
|
||||
}));
|
||||
options.tray.hidden = !values.length;
|
||||
if (values.length) update(values, optimized);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
revoke(previewUrl);
|
||||
thumbnailUrls.forEach(revoke);
|
||||
previewUrl = '';
|
||||
thumbnailUrls = [];
|
||||
activeIndex = 0;
|
||||
options.tray.replaceChildren();
|
||||
options.tray.hidden = true;
|
||||
options.earlier.disabled = true;
|
||||
options.later.disabled = true;
|
||||
if (options.note) {
|
||||
options.note.value = '';
|
||||
options.note.disabled = true;
|
||||
}
|
||||
if (options.edit) options.edit.disabled = true;
|
||||
if (options.noteLabel) options.noteLabel.textContent = 'Evidence note (optional)';
|
||||
}
|
||||
|
||||
function afterRemoval(removedIndex, previousCount) {
|
||||
activeIndex = Math.min(removedIndex, previousCount - 2);
|
||||
}
|
||||
|
||||
function move(offset) {
|
||||
const total = count();
|
||||
const destination = activeIndex + offset;
|
||||
if (destination < 0 || destination >= total) return Promise.resolve();
|
||||
controller.move(activeIndex, destination);
|
||||
activeIndex = destination;
|
||||
return controller.serialize().then(value => {
|
||||
render(value, activeIndex);
|
||||
options.status.textContent = 'Screenshot moved to position ' + (activeIndex + 1) + ' of ' + total + '.';
|
||||
});
|
||||
}
|
||||
|
||||
options.earlier.addEventListener('click', () => move(-1));
|
||||
options.later.addEventListener('click', () => move(1));
|
||||
options.note?.addEventListener('input', event => {
|
||||
controller.setNote(activeIndex, event.target.value);
|
||||
});
|
||||
clear();
|
||||
return { activeIndex: () => activeIndex, afterRemoval, clear, render, setBusy };
|
||||
}
|
||||
|
||||
return { create, optimizeImage, MAX_PIXELS };
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createIssueFilingReceipt = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
function createIssueFilingReceipt({
|
||||
root, heading, key, title, ownership, openLink, shareButton, filedButton,
|
||||
relatedButton, fileAnotherButton, doneButton, status, navigator = {}, clipboard = navigator.clipboard,
|
||||
onFileRelated = function () {},
|
||||
onFileAnother = function () {},
|
||||
onViewFiled = function () {},
|
||||
}) {
|
||||
let active = null;
|
||||
let relatedPlan = null;
|
||||
let restoreFocus = null;
|
||||
|
||||
function close() {
|
||||
root.hidden = true;
|
||||
active = null;
|
||||
relatedPlan = null;
|
||||
const target = restoreFocus;
|
||||
restoreFocus = null;
|
||||
target?.focus?.();
|
||||
}
|
||||
|
||||
async function share() {
|
||||
if (!active) return;
|
||||
const payload = {
|
||||
title: active.repository + '#' + active.number + ' · ' + active.title,
|
||||
text: active.title,
|
||||
url: active.url,
|
||||
};
|
||||
if (typeof navigator.share === 'function') {
|
||||
try {
|
||||
await navigator.share(payload);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
}
|
||||
}
|
||||
if (typeof clipboard?.writeText !== 'function') {
|
||||
status.textContent = 'Issue link is ready in Open in Gitea.';
|
||||
return;
|
||||
}
|
||||
await clipboard.writeText(active.url);
|
||||
status.textContent = 'Issue link copied.';
|
||||
}
|
||||
|
||||
function show(issue, returnTarget, reusablePlan = null) {
|
||||
active = issue;
|
||||
relatedPlan = reusablePlan;
|
||||
restoreFocus = returnTarget || null;
|
||||
key.textContent = issue.repository + '#' + issue.number;
|
||||
title.textContent = issue.title;
|
||||
ownership.textContent = issue.assignees?.length
|
||||
? 'Assigned to @' + issue.assignees.join(', @') + '.'
|
||||
: 'Created with no owner.';
|
||||
openLink.href = issue.url;
|
||||
status.textContent = '';
|
||||
if (relatedButton) relatedButton.hidden = !relatedPlan;
|
||||
root.hidden = false;
|
||||
heading.focus();
|
||||
}
|
||||
|
||||
shareButton.addEventListener('click', share);
|
||||
filedButton?.addEventListener('click', () => {
|
||||
if (!active) return;
|
||||
const issue = active;
|
||||
close();
|
||||
onViewFiled(issue);
|
||||
});
|
||||
root.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || root.hidden) return;
|
||||
event.preventDefault();
|
||||
close();
|
||||
});
|
||||
doneButton.addEventListener('click', close);
|
||||
relatedButton?.addEventListener('click', () => {
|
||||
if (!relatedPlan) return;
|
||||
const plan = relatedPlan;
|
||||
close();
|
||||
onFileRelated(plan);
|
||||
});
|
||||
fileAnotherButton.addEventListener('click', () => {
|
||||
close();
|
||||
onFileAnother();
|
||||
});
|
||||
|
||||
return { show, close, share };
|
||||
}
|
||||
|
||||
return createIssueFilingReceipt;
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createIssueFilingReview = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const INTENT_LABELS = {
|
||||
'create-and-assign': 'Create & assign to me',
|
||||
'create-and-start': 'Create & start',
|
||||
'follow-up-and-next': 'Create follow-up & next',
|
||||
};
|
||||
|
||||
function clone(value) {
|
||||
if (typeof structuredClone === 'function') return structuredClone(value);
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
let reviewed = null;
|
||||
let trigger = null;
|
||||
let busy = false;
|
||||
let evidenceItems = [];
|
||||
const generatedUrls = new Set();
|
||||
|
||||
function releaseEvidenceUrls() {
|
||||
generatedUrls.forEach(url => options.revokeObjectURL?.(url));
|
||||
generatedUrls.clear();
|
||||
evidenceItems = [];
|
||||
}
|
||||
|
||||
function evidenceUrl(attachment) {
|
||||
if (attachment?.blob) {
|
||||
const url = options.createObjectURL(attachment.blob);
|
||||
generatedUrls.add(url);
|
||||
return url;
|
||||
}
|
||||
if (attachment?.data) {
|
||||
return 'data:' + String(attachment.contentType || 'application/octet-stream') +
|
||||
';base64,' + String(attachment.data);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function selectEvidence(index) {
|
||||
const selected = evidenceItems[index];
|
||||
if (!selected) return;
|
||||
evidenceItems.forEach((item, position) =>
|
||||
item.button.setAttribute('aria-pressed', position === index ? 'true' : 'false'));
|
||||
const filename = selected.attachment.filename || 'Screenshot';
|
||||
options.evidencePreview.hidden = false;
|
||||
options.evidenceImage.src = selected.url;
|
||||
options.evidenceImage.alt = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length + ': ' + filename;
|
||||
options.evidencePosition.textContent = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length;
|
||||
options.evidenceFilename.textContent = filename;
|
||||
options.evidenceNote.textContent = selected.attachment.note || 'No evidence note.';
|
||||
}
|
||||
|
||||
function renderVisualEvidence(attachments) {
|
||||
releaseEvidenceUrls();
|
||||
options.evidenceEmpty.hidden = attachments.length > 0;
|
||||
options.evidencePreview.hidden = true;
|
||||
options.evidenceImage.src = '';
|
||||
evidenceItems = attachments.map((attachment, index) => {
|
||||
const url = evidenceUrl(attachment);
|
||||
const item = options.document.createElement('li');
|
||||
const button = options.document.createElement('button');
|
||||
const image = options.document.createElement('img');
|
||||
const filename = attachment.filename || 'Screenshot';
|
||||
button.setAttribute('type', 'button');
|
||||
button.className = 'issue-filing-evidence-selector';
|
||||
button.setAttribute('aria-label', 'Preview evidence ' + (index + 1) + ' of ' + attachments.length + ': ' + filename);
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
image.src = url;
|
||||
image.alt = '';
|
||||
button.appendChild(image);
|
||||
item.appendChild(button);
|
||||
return { attachment, button, item, url };
|
||||
});
|
||||
evidenceItems.forEach((entry, index) =>
|
||||
entry.button.addEventListener('click', () => selectEvidence(index)));
|
||||
options.evidenceList.replaceChildren(...evidenceItems.map(entry => entry.item));
|
||||
if (evidenceItems.length) selectEvidence(0);
|
||||
}
|
||||
|
||||
function close() {
|
||||
options.sheet.hidden = true;
|
||||
const previousTrigger = trigger;
|
||||
trigger = null;
|
||||
reviewed = null;
|
||||
busy = false;
|
||||
releaseEvidenceUrls();
|
||||
options.confirmButton.disabled = false;
|
||||
previousTrigger?.focus();
|
||||
}
|
||||
|
||||
function render(payload) {
|
||||
const draft = payload.draft;
|
||||
options.repository.textContent = draft.repository || 'No repository';
|
||||
options.intent.textContent = draft.unassigned === true ? 'Create unassigned' :
|
||||
(INTENT_LABELS[payload.intent] || payload.intent);
|
||||
if (options.issueType) options.issueType.textContent = draft.templateName || 'Blank issue';
|
||||
options.title.textContent = draft.title;
|
||||
options.body.textContent = draft.body || 'No note provided.';
|
||||
const labels = (draft.labels || []).map(label => typeof label === 'string' ? label : label.name);
|
||||
const milestone = draft.milestone?.title || draft.milestoneTitle || 'No milestone';
|
||||
const dueDate = draft.dueDate || draft.due_date || 'No due date';
|
||||
const owner = draft.unassigned === true ? 'Owner: No owner' : (draft.assignee
|
||||
? 'Owner: ' + (draft.assigneeName || draft.assignee) + ' (@' + draft.assignee + ')'
|
||||
: 'Assigned to you');
|
||||
const planning = [];
|
||||
if (Number.isInteger(draft.estimateMinutes)) {
|
||||
planning.push('Estimate: ' + draft.estimateMinutes + ' min');
|
||||
const projected = draft.todayCapacity?.projectedMinutes;
|
||||
if (Number.isInteger(projected)) planning.push(projected >= 0
|
||||
? 'Today after start: ' + projected + ' min free'
|
||||
: 'Today after start: ' + Math.abs(projected) + ' min over capacity');
|
||||
}
|
||||
options.metadata.textContent = [
|
||||
labels.length ? 'Labels: ' + labels.join(', ') : 'No labels',
|
||||
'Milestone: ' + milestone,
|
||||
'Due: ' + dueDate,
|
||||
owner,
|
||||
...planning,
|
||||
].join(' · ');
|
||||
if (options.blockerList) {
|
||||
options.blockerList.replaceChildren(...(draft.blockers || []).map(blocker => {
|
||||
const item = options.document.createElement('li');
|
||||
item.textContent = blocker.repository + ' #' + blocker.number + ' — ' + blocker.title;
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
const attachments = (draft.attachments || (draft.attachment ? [draft.attachment] : [])).filter(Boolean);
|
||||
if (options.evidencePreview) {
|
||||
renderVisualEvidence(attachments);
|
||||
return;
|
||||
}
|
||||
options.evidenceList.replaceChildren(...attachments.map((attachment, index) => {
|
||||
const item = options.document.createElement('li');
|
||||
item.textContent = (index + 1) + '. ' + (attachment.filename || 'Screenshot') +
|
||||
(attachment.note ? ' — ' + attachment.note : '');
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
function open(payload, opener) {
|
||||
reviewed = clone(payload);
|
||||
trigger = opener || null;
|
||||
busy = false;
|
||||
options.confirmButton.disabled = false;
|
||||
render(reviewed);
|
||||
options.sheet.hidden = false;
|
||||
options.backButton.focus();
|
||||
}
|
||||
|
||||
options.backButton.addEventListener('click', close);
|
||||
options.confirmButton.addEventListener('click', async () => {
|
||||
if (!reviewed || busy) return;
|
||||
busy = true;
|
||||
options.confirmButton.disabled = true;
|
||||
try {
|
||||
await options.onConfirm(reviewed);
|
||||
close();
|
||||
} catch (error) {
|
||||
busy = false;
|
||||
options.confirmButton.disabled = false;
|
||||
if (options.status) options.status.textContent = error.message;
|
||||
options.confirmButton.focus();
|
||||
}
|
||||
});
|
||||
options.document.addEventListener('keydown', event => {
|
||||
if (!options.sheet.hidden && event.key === 'Escape' && !busy) {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
});
|
||||
return { close, open };
|
||||
}
|
||||
|
||||
return create;
|
||||
});
|
||||
+212
-40
@@ -11,10 +11,72 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
|
||||
if (blob) return { filename, contentType, blob };
|
||||
if (!data && value?.stored === true) return { filename, contentType, stored: true };
|
||||
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
const noteValue = note ? { note } : {};
|
||||
if (blob) return { filename, contentType, blob, ...noteValue };
|
||||
if (!data && value?.stored === true) return { filename, contentType, stored: true, ...noteValue };
|
||||
if (!data) return undefined;
|
||||
return { filename, contentType, data };
|
||||
return { filename, contentType, data, ...noteValue };
|
||||
}
|
||||
|
||||
function captureAttachments(values) {
|
||||
if (!Array.isArray(values)) return undefined;
|
||||
if (values.length > 5) throw new Error('You can attach up to 5 screenshots.');
|
||||
const attachments = values.map(captureAttachment);
|
||||
if (attachments.some(value => !value)) {
|
||||
throw new Error('Some screenshots are unavailable. Choose them again before queueing.');
|
||||
}
|
||||
return attachments.length ? attachments : undefined;
|
||||
}
|
||||
|
||||
function captureBlockers(values) {
|
||||
if (!Array.isArray(values)) return undefined;
|
||||
const seen = new Set();
|
||||
const blockers = [];
|
||||
for (const value of values) {
|
||||
const repository = String(value?.repository || '').trim();
|
||||
const number = Number(value?.number);
|
||||
const key = repository + '#' + number;
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
||||
!Number.isInteger(number) || number < 1 || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
blockers.push({
|
||||
repository, number,
|
||||
title: String(value?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255),
|
||||
});
|
||||
if (blockers.length === 5) break;
|
||||
}
|
||||
return blockers.length ? blockers : undefined;
|
||||
}
|
||||
|
||||
function captureEstimate(value) {
|
||||
const estimate = Number(value);
|
||||
return Number.isInteger(estimate) && estimate >= 5 && estimate <= 1440 ? estimate : undefined;
|
||||
}
|
||||
|
||||
function captureRelatedDraft(value) {
|
||||
const repository = String(value?.repository || '');
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return undefined;
|
||||
const plan = {
|
||||
repository, title:'', body:String(value?.body || '').slice(0, 9000),
|
||||
labelIds:Array.from(new Set((Array.isArray(value?.labelIds) ? value.labelIds : [])
|
||||
.filter(id => Number.isInteger(id) && id > 0))).slice(0, 20),
|
||||
};
|
||||
const milestoneId = Number(value?.milestoneId);
|
||||
if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(String(value?.dueDate || ''))) plan.dueDate = String(value.dueDate);
|
||||
if (value?.unassigned === true) plan.unassigned = true;
|
||||
else if (/^[A-Za-z0-9_.-]+$/.test(String(value?.assignee || ''))) {
|
||||
plan.assignee = String(value.assignee);
|
||||
plan.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
const templateId = String(value?.templateId || '').slice(0, 80);
|
||||
if (templateId) {
|
||||
plan.templateId = templateId;
|
||||
plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80);
|
||||
plan.capturedBody = '';
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function read() {
|
||||
@@ -51,11 +113,27 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
const assignee = /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
|
||||
? String(draft.assignee) : '';
|
||||
if (assignee) {
|
||||
item.assignee = assignee;
|
||||
item.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
if (draft?.unassigned === true) item.unassigned = true;
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
|
||||
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||
if (draft?.completionIntent === 'create-and-start' && !assignee && !item.unassigned) {
|
||||
item.completionIntent = 'create-and-start';
|
||||
item.estimateMinutes = captureEstimate(draft?.estimateMinutes);
|
||||
}
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
if (attachment) item.attachment = attachment;
|
||||
const attachments = captureAttachments(draft?.attachments);
|
||||
if (attachments) item.attachments = attachments;
|
||||
const blockers = captureBlockers(draft?.blockers);
|
||||
if (blockers) item.blockers = blockers;
|
||||
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
|
||||
if (relatedDraft) item.relatedDraft = relatedDraft;
|
||||
item.operationId = item.id;
|
||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||
item.milestoneId = Number(draft.milestoneId);
|
||||
@@ -65,14 +143,21 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
|
||||
function localIndexItem(item) {
|
||||
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
|
||||
const hasAttachmentBytes = item?.attachment?.data || item?.attachment?.blob;
|
||||
const hasBundleBytes = Array.isArray(item?.attachments) &&
|
||||
item.attachments.some(value => value?.data || value?.blob);
|
||||
if (!hasAttachmentBytes && !hasBundleBytes) return item;
|
||||
return {
|
||||
...item,
|
||||
attachment: {
|
||||
...(hasAttachmentBytes ? {attachment: {
|
||||
filename: item.attachment.filename,
|
||||
contentType: item.attachment.contentType,
|
||||
stored: true,
|
||||
},
|
||||
}} : {}),
|
||||
...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({
|
||||
filename:value.filename, contentType:value.contentType, stored:true,
|
||||
...(value.note ? {note:value.note} : {}),
|
||||
}))} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,50 +205,90 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
async function hydrateForEdit(id) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return null;
|
||||
if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) return { ...item };
|
||||
const storedBundle = Array.isArray(item.attachments) && item.attachments.some(value => value?.stored);
|
||||
const storedAttachment = item.attachment?.stored && !item.attachment.data && !item.attachment.blob;
|
||||
if (!storedAttachment && !storedBundle) return { ...item };
|
||||
if (!backgroundSync?.get) {
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
const durable = await backgroundSync.get(id);
|
||||
const attachment = captureAttachment(durable?.attachment);
|
||||
if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
|
||||
const attachments = captureAttachments(durable?.attachments);
|
||||
if (durable?.operationId !== item.operationId ||
|
||||
(storedAttachment && (!attachment?.data && !attachment?.blob)) ||
|
||||
(storedBundle && (!attachments || attachments.length !== item.attachments.length))) {
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
return { ...item, attachment };
|
||||
return {
|
||||
...item,
|
||||
...(attachment ? {attachment} : {}),
|
||||
...(attachments ? {attachments} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function prepareUpdate(id, draft) {
|
||||
let updated = null;
|
||||
const items = read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const nextRepository = String(draft?.repository || '');
|
||||
const nextTitle = String(draft?.title || '');
|
||||
const nextBody = String(draft?.body || '');
|
||||
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
||||
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
|
||||
const repository = String(draft?.repository || '');
|
||||
const title = String(draft?.title || '');
|
||||
const body = String(draft?.body || '');
|
||||
const labelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
||||
const unassigned = draft?.unassigned === true;
|
||||
const assignee = !unassigned && /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
|
||||
? String(draft.assignee) : undefined;
|
||||
const assigneeName = assignee
|
||||
? String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255)
|
||||
: undefined;
|
||||
const milestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
|
||||
? Number(draft.milestoneId) : undefined;
|
||||
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||
const dueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||
? String(draft.dueDate) : undefined;
|
||||
const nextAttachment = captureAttachment(draft?.attachment);
|
||||
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
|
||||
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
||||
|| attachmentChanged;
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
const attachments = captureAttachments(draft?.attachments);
|
||||
const blockers = captureBlockers(draft?.blockers);
|
||||
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
|
||||
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(attachment || null) ||
|
||||
JSON.stringify(item.attachments || null) !== JSON.stringify(attachments || null);
|
||||
const changed = item.repository !== repository || item.title !== title || item.body !== body
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(labelIds)
|
||||
|| Boolean(item.unassigned) !== unassigned
|
||||
|| item.assignee !== assignee || item.assigneeName !== assigneeName
|
||||
|| item.milestoneId !== milestoneId || item.dueDate !== dueDate
|
||||
|| item.estimateMinutes !== captureEstimate(draft?.estimateMinutes)
|
||||
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(blockers || null)
|
||||
|| JSON.stringify(item.relatedDraft || null) !== JSON.stringify(relatedDraft || null);
|
||||
updated = {
|
||||
...item,
|
||||
repository: nextRepository, title: nextTitle,
|
||||
body: nextBody, labelIds: nextLabelIds,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
|
||||
repository, title, body, labelIds,
|
||||
unassigned: unassigned || undefined,
|
||||
assignee, assigneeName, milestoneId, dueDate,
|
||||
attachment, attachments, blockers, relatedDraft,
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
if (draft?.completionIntent === 'create-and-start') updated.completionIntent = 'create-and-start';
|
||||
else delete updated.completionIntent;
|
||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||
if (nextDueDate === undefined) delete updated.dueDate;
|
||||
if (nextAttachment === undefined) delete updated.attachment;
|
||||
if (attachmentChanged) delete updated.attachmentMarkdown;
|
||||
if (draft?.completionIntent === 'create-and-start' && !assignee && !unassigned) {
|
||||
updated.completionIntent = 'create-and-start';
|
||||
updated.estimateMinutes = captureEstimate(draft?.estimateMinutes);
|
||||
} else {
|
||||
delete updated.completionIntent;
|
||||
delete updated.estimateMinutes;
|
||||
}
|
||||
if (assignee === undefined) {
|
||||
delete updated.assignee;
|
||||
delete updated.assigneeName;
|
||||
}
|
||||
if (!unassigned) delete updated.unassigned;
|
||||
if (milestoneId === undefined) delete updated.milestoneId;
|
||||
if (dueDate === undefined) delete updated.dueDate;
|
||||
if (attachment === undefined) delete updated.attachment;
|
||||
if (attachments === undefined) delete updated.attachments;
|
||||
if (blockers === undefined) delete updated.blockers;
|
||||
if (relatedDraft === undefined) delete updated.relatedDraft;
|
||||
if (attachmentChanged) {
|
||||
delete updated.attachmentMarkdown;
|
||||
delete updated.attachmentMarkdowns;
|
||||
}
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
@@ -213,6 +338,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
function evidenceMarkdown(attachment, markdown, index) {
|
||||
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
if (!note) return markdown;
|
||||
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
|
||||
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
|
||||
}
|
||||
|
||||
function attachmentMultipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
@@ -237,34 +369,68 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.unassigned ? { unassigned: true } : {}),
|
||||
...(item.assignee ? { assignee: item.assignee } : {}),
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
});
|
||||
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
|
||||
if (item.attachment || item.attachments || item.blockers?.length) {
|
||||
persistDeliveryStage(item.id, { deliveredIssue: issue });
|
||||
}
|
||||
}
|
||||
if (!item.attachment) return issue;
|
||||
let markdown = item.attachmentMarkdown;
|
||||
if (!markdown) {
|
||||
const blockers = Array.isArray(item.blockers) ? item.blockers : [];
|
||||
let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length);
|
||||
for (let index = deliveredBlockers; index < blockers.length; index += 1) {
|
||||
const blocker = blockers[index];
|
||||
await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/blockers',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'blocker-' + index),
|
||||
},
|
||||
body: JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}),
|
||||
},
|
||||
);
|
||||
deliveredBlockers = index + 1;
|
||||
persistDeliveryStage(item.id, { deliveredIssue:issue, deliveredBlockers });
|
||||
}
|
||||
const attachments = Array.isArray(item.attachments) ? item.attachments :
|
||||
(item.attachment ? [item.attachment] : []);
|
||||
if (!attachments.length) return issue;
|
||||
const markdowns = Array.isArray(item.attachmentMarkdowns)
|
||||
? item.attachmentMarkdowns.slice(0, attachments.length)
|
||||
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
|
||||
for (let index = markdowns.length; index < attachments.length; index += 1) {
|
||||
const uploaded = await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
'Idempotency-Key': stageOperationId(
|
||||
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
|
||||
),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
body: attachmentMultipart(attachments[index]),
|
||||
},
|
||||
);
|
||||
markdown = String(uploaded?.markdown || '');
|
||||
const markdown = String(uploaded?.markdown || '');
|
||||
if (!markdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
|
||||
markdowns.push(markdown);
|
||||
persistDeliveryStage(item.id, {
|
||||
deliveredIssue: issue, attachmentMarkdowns:markdowns.slice(),
|
||||
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||
});
|
||||
}
|
||||
const markdown = markdowns.map((value, index) =>
|
||||
evidenceMarkdown(attachments[index], value, index)).join('\n\n');
|
||||
await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
|
||||
{
|
||||
@@ -329,6 +495,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
const completions = [];
|
||||
const filings = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
@@ -337,17 +504,19 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const result = await sendItem(item, currentLogin);
|
||||
if (result.issue) {
|
||||
confirmed.push(result.issue);
|
||||
if (result.item?.relatedDraft) filings.push({issue:result.issue, relatedDraft:result.item.relatedDraft});
|
||||
if (result.item?.completionIntent) completions.push({
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
estimateMinutes: result.item.estimateMinutes,
|
||||
issue: result.issue,
|
||||
});
|
||||
}
|
||||
if (result.transient) break;
|
||||
}
|
||||
return { confirmed, completions, remaining: read(), blocked };
|
||||
return { confirmed, completions, filings, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
@@ -376,11 +545,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const result = await sendItem(queued, currentLogin);
|
||||
return {
|
||||
confirmed: result.issue ? [result.issue] : [],
|
||||
filings: result.issue && result.item?.relatedDraft ? [{issue:result.issue, relatedDraft:result.item.relatedDraft}] : [],
|
||||
completions: result.issue && result.item?.completionIntent ? [{
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
estimateMinutes: result.item.estimateMinutes,
|
||||
issue: result.issue,
|
||||
}] : [],
|
||||
remaining: read(), blocked: 0,
|
||||
@@ -425,6 +596,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
intent: item.completionIntent,
|
||||
ownerLogin: item.ownerLogin,
|
||||
operationId: item.operationId,
|
||||
estimateMinutes: item.estimateMinutes,
|
||||
issue: item.deliveredIssue,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -38,8 +38,12 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
readOnly(item) {
|
||||
return Boolean(item?.is_filed && !item?.is_assigned);
|
||||
},
|
||||
load(item) {
|
||||
return fetchJson(issuePath(item) + '/detail', {
|
||||
const access = this.readOnly(item) ? '?access=filed' : '';
|
||||
return fetchJson(issuePath(item) + '/detail' + access, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -90,7 +90,8 @@ function createLaterPicker({
|
||||
onState({ open:true, message:result.message, value:String(input || '') });
|
||||
return false;
|
||||
}
|
||||
const confirmed = onConfirm(item, result.value, context);
|
||||
const confirmed = typeof item?.confirm === 'function' ? item.confirm(result.value) :
|
||||
onConfirm(item, result.value, context);
|
||||
if (confirmed !== true && typeof confirmed !== 'function') return false;
|
||||
committed = true;
|
||||
afterClose = typeof confirmed === 'function' ? confirmed : null;
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
if (name === 'filed' && options.openFiled) return options.openFiled();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
if (!action) {
|
||||
|
||||
@@ -91,9 +91,9 @@
|
||||
}
|
||||
|
||||
function updateQueues(counts) {
|
||||
const names = 'today agenda attention update later draft'.split(' ');
|
||||
const names = 'today agenda attention update filed later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const actionableNames = ['today', 'attention', 'update', 'later', 'draft'];
|
||||
const actionableNames = ['today', 'attention', 'update', 'filed', 'later', 'draft'];
|
||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
@@ -119,6 +119,7 @@
|
||||
+ ', Agenda ' + normalized.agenda + ' due'
|
||||
+ ', Attention ' + normalized.attention
|
||||
+ ', Updates ' + normalized.update
|
||||
+ ', Filed ' + normalized.filed
|
||||
+ ', Later ' + normalized.later
|
||||
+ ', Drafts ' + normalized.draft
|
||||
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
|
||||
|
||||
+12
-1
@@ -70,17 +70,19 @@ function buildMyWork(data, now = new Date()) {
|
||||
);
|
||||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
const isFiled = (item.work_reasons || []).includes('created_by_me');
|
||||
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||
const normalized = {
|
||||
...item,
|
||||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
is_filed: isFiled,
|
||||
is_assigned: assigned,
|
||||
has_update: false,
|
||||
...(due ? { due_label: due.label } : {}),
|
||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||
(due && due.priority < 4 ? due.label :
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))),
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' : 'Open work')))),
|
||||
_priority: priorityLabel ? 0 :
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
|
||||
};
|
||||
@@ -651,6 +653,7 @@ function createNotificationReplier({
|
||||
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
let filtered = items;
|
||||
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
|
||||
else if (selectedFilter === 'filed') filtered = items.filter((item) => item.is_filed);
|
||||
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||
@@ -958,10 +961,17 @@ function findQueueItems(items, query) {
|
||||
});
|
||||
}
|
||||
|
||||
function filedFollowUpTarget(items) {
|
||||
const item = (items || []).find(candidate => candidate?.is_filed);
|
||||
if (!item) return null;
|
||||
return { kind:item.has_update ? 'update' : 'issue', item };
|
||||
}
|
||||
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
attention: items.filter(needsAttention).length,
|
||||
filed: items.filter((item) => item.is_filed).length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
@@ -983,6 +993,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.findQueueItems = findQueueItems;
|
||||
buildMyWork.filedFollowUpTarget = filedFollowUpTarget;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
(function (root, factory) {
|
||||
const createSavedSearches = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSavedSearches;
|
||||
if (root) root.createSavedSearches = createSavedSearches;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function createSavedSearches(options) {
|
||||
const fetchJson = options.fetchJson;
|
||||
const createId = options.createId || (() => crypto.randomUUID().replace(/-/g, ''));
|
||||
const onOpen = options.onOpen;
|
||||
const onState = options.onState || (() => {});
|
||||
let current = { revision:0, views:[] };
|
||||
|
||||
function snapshot() {
|
||||
return { revision:current.revision, views:current.views.map(view => ({ ...view })) };
|
||||
}
|
||||
|
||||
function publish(status, message) {
|
||||
onState({ ...snapshot(), status, ...(message ? { message } : {}) });
|
||||
}
|
||||
|
||||
function normalizeSearch(name, search, id) {
|
||||
const cleanName = String(name || '').trim();
|
||||
const query = String(search?.query || '').trim();
|
||||
if (!cleanName || cleanName.length > 60) throw new Error('Name must be between 1 and 60 characters.');
|
||||
if (query.length < 2 || query.length > 200) throw new Error('Search query must be between 2 and 200 characters.');
|
||||
return {
|
||||
id, name:cleanName, query,
|
||||
kind:['all', 'issue', 'pull'].includes(search?.kind) ? search.kind : 'all',
|
||||
state:['all', 'open', 'closed'].includes(search?.state) ? search.state : 'all',
|
||||
repository:String(search?.repository || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function replace(views) {
|
||||
publish('saving');
|
||||
try {
|
||||
current = await fetchJson('api/v1/saved-searches', {
|
||||
method:'PUT', headers:{ 'Content-Type':'application/json' },
|
||||
body:JSON.stringify({ revision:current.revision, views }),
|
||||
});
|
||||
publish('ready');
|
||||
return snapshot();
|
||||
} catch (error) {
|
||||
const conflict = error?.status === 409 && error?.payload?.detail?.snapshot;
|
||||
if (conflict) {
|
||||
current = conflict;
|
||||
publish('conflict', 'Saved searches changed on another device.');
|
||||
} else {
|
||||
publish('error', 'Saved searches could not sync. Search still works.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async load() {
|
||||
publish('loading');
|
||||
try {
|
||||
current = await fetchJson('api/v1/saved-searches');
|
||||
publish('ready');
|
||||
} catch (error) {
|
||||
publish('error', 'Saved searches could not load. Search still works.');
|
||||
}
|
||||
return snapshot();
|
||||
},
|
||||
save(name, search) {
|
||||
if (current.views.length >= 20) return Promise.reject(new Error('Saved searches are limited to 20.'));
|
||||
const created = normalizeSearch(name, search, createId());
|
||||
return replace(current.views.concat(created));
|
||||
},
|
||||
rename(id, name) {
|
||||
const existing = current.views.find(view => view.id === id);
|
||||
if (!existing) return Promise.reject(new Error('Saved search no longer exists.'));
|
||||
const changed = normalizeSearch(name, existing, existing.id);
|
||||
return replace(current.views.map(view => view.id === id ? changed : view));
|
||||
},
|
||||
remove(id) {
|
||||
if (!current.views.some(view => view.id === id)) return Promise.resolve(snapshot());
|
||||
return replace(current.views.filter(view => view.id !== id));
|
||||
},
|
||||
open(id) {
|
||||
const selected = current.views.find(view => view.id === id);
|
||||
if (selected) onOpen({ ...selected });
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
createSavedSearches.mount = function mountSavedSearches(document, fetch, search, applyScope, history) {
|
||||
const query = selector => document.querySelector(selector);
|
||||
const input = query('#cmd-input');
|
||||
const request = async (url, init) => {
|
||||
const response = await fetch(url, init);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.detail?.message || payload.detail || 'Saved Search request failed.');
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
let controller;
|
||||
function render(state) {
|
||||
const list = query('#saved-search-list');
|
||||
const status = query('#saved-search-status');
|
||||
list.replaceChildren();
|
||||
status.textContent = ({loading:'Loading…', saving:'Syncing…', ready:'',
|
||||
conflict:state.message, error:state.message})[state.status] || '';
|
||||
state.views.forEach(view => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'saved-search-row';
|
||||
const open = document.createElement('button');
|
||||
open.type = 'button'; open.className = 'saved-search-open saved-search-action';
|
||||
open.textContent = view.name; open.title = view.query;
|
||||
open.addEventListener('click', () => controller.open(view.id));
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'saved-search-row-actions';
|
||||
for (const action of ['Rename', 'Delete']) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.className = 'saved-search-action'; button.textContent = action;
|
||||
button.setAttribute('aria-label', action + ' ' + view.name);
|
||||
button.addEventListener('click', async () => {
|
||||
if (action === 'Rename') {
|
||||
const name = globalThis.prompt('Rename saved search', view.name);
|
||||
if (name !== null && name.trim()) await controller.rename(view.id, name).catch(() => {});
|
||||
} else if (globalThis.confirm('Delete saved search “' + view.name + '”?')) {
|
||||
await controller.remove(view.id).catch(() => {});
|
||||
}
|
||||
});
|
||||
actions.append(button);
|
||||
}
|
||||
row.append(open, actions); list.append(row);
|
||||
});
|
||||
}
|
||||
controller = createSavedSearches({ fetchJson:request, onState:render, onOpen:view => {
|
||||
search.setQuery('');
|
||||
input.value = view.query;
|
||||
applyScope(view);
|
||||
search.setQuery(view.query);
|
||||
history.update({query:view.query, scope:view});
|
||||
input.focus();
|
||||
}});
|
||||
query('#save-current-search').addEventListener('click', async () => {
|
||||
const name = query('#saved-search-name').value.trim();
|
||||
const current = {query:input.value.trim(), kind:query('#cmd-search-kind').value,
|
||||
state:query('#cmd-search-state').value, repository:query('#cmd-search-repository').value.trim()};
|
||||
if (!name || current.query.length < 2) {
|
||||
query('#saved-search-status').textContent = 'Enter a name and at least 2 search characters.';
|
||||
return;
|
||||
}
|
||||
await controller.save(name, current).then(() => { query('#saved-search-name').value = ''; })
|
||||
.catch(error => { query('#saved-search-status').textContent = error.message; });
|
||||
});
|
||||
return controller;
|
||||
};
|
||||
|
||||
return createSavedSearches;
|
||||
});
|
||||
@@ -57,15 +57,23 @@
|
||||
if (detail?.claimable) return claim(detail);
|
||||
throw new Error('This issue is no longer available to assign.');
|
||||
}
|
||||
return { identity, eligible, start, cancel, toggle, snapshot, prepare, limit:maximum };
|
||||
function releaseRepository() {
|
||||
const repositories = new Set(Array.from(selected.values()).map(item => item.repository));
|
||||
return repositories.size === 1 ? repositories.values().next().value : null;
|
||||
}
|
||||
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
|
||||
}
|
||||
|
||||
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
|
||||
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
|
||||
acceptIssue, lookup, render, escapeHtml, escapeAttribute
|
||||
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute,
|
||||
laterWork = null, laterPicker = null
|
||||
) {
|
||||
const get = selector => document.querySelector(selector);
|
||||
let processor;
|
||||
let laterProcessor;
|
||||
let releaseProcessor;
|
||||
let releaseContext = null;
|
||||
const plan = createSearchBatchPlan({
|
||||
resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}),
|
||||
claim:detail => fetchJson(
|
||||
@@ -77,6 +85,8 @@
|
||||
get('#search-batch-actions').hidden = !state.active;
|
||||
get('#select-search-results').hidden = state.active;
|
||||
get('#queue-selected-search-results').disabled = state.count === 0;
|
||||
get('#defer-selected-search-results').disabled = state.count === 0;
|
||||
get('#plan-selected-search-results').disabled = state.count === 0;
|
||||
get('#search-selection-status').textContent = state.count ?
|
||||
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
|
||||
render();
|
||||
@@ -85,10 +95,24 @@
|
||||
processor = batchFactory({
|
||||
capacity:() => Math.max(0, todayWork.limit - todayWork.read().length),
|
||||
owner:getOwner,
|
||||
timeBudget:() => {
|
||||
const planning = todayWork.planning();
|
||||
return {
|
||||
capacity_minutes:planning.capacity_minutes,
|
||||
planned_minutes:Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0),
|
||||
};
|
||||
},
|
||||
journalName:'search-today-batch',
|
||||
autoMount:false,
|
||||
claim:item => plan.prepare(item),
|
||||
queue:confirmed => queueToday(acceptIssue(confirmed)),
|
||||
persistEstimate:(confirmed, minutes) => {
|
||||
const planning = todayWork.planning();
|
||||
planning.estimates[todayWork.identity(confirmed)] = minutes;
|
||||
todayWork.replacePlanning(planning);
|
||||
todaySync.enqueueConfiguration(planning.capacity_minutes, planning.estimates);
|
||||
todaySync.flush();
|
||||
},
|
||||
onProgress:progress => {
|
||||
get('#queue-selected-search-results').disabled = progress.status === 'running';
|
||||
if (progress.status === 'running') {
|
||||
@@ -96,19 +120,186 @@
|
||||
} else if (progress.status === 'full') {
|
||||
get('#search-selection-status').textContent = 'Today has ' + progress.available + ' remaining slot' +
|
||||
(progress.available === 1 ? '.' : 's.');
|
||||
} else if (progress.status === 'estimates-required') {
|
||||
get('#search-batch-estimate-summary').textContent = 'Estimate every selected issue.';
|
||||
} else if (progress.status === 'over-budget') {
|
||||
get('#search-batch-estimate-summary').textContent = progress.requested_minutes +
|
||||
' min selected · ' + progress.remaining_minutes + ' min remain.';
|
||||
} else if (progress.status === 'complete') {
|
||||
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||
progress.queued.length + ' queued · ' + progress.failed.length + ' need retry.' :
|
||||
progress.queued.length + ' added to Today.';
|
||||
get('#resume-search-batch').hidden = processor.pending() === 0;
|
||||
closeEstimateReview();
|
||||
if (!progress.failed.length) plan.cancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
laterProcessor = batchFactory({
|
||||
capacity:() => plan.limit,
|
||||
owner:getOwner,
|
||||
journalName:'search-later-batch',
|
||||
queueFailureReason:'assigned but Later could not be saved',
|
||||
autoMount:false,
|
||||
claim:item => plan.prepare(item),
|
||||
queue:(confirmed, context) => {
|
||||
const outcome = laterWork?.defer(acceptIssue(confirmed), context?.until);
|
||||
return outcome === 'deferred' ? 'queued' : outcome;
|
||||
},
|
||||
onProgress:progress => {
|
||||
get('#defer-selected-search-results').disabled = progress.status === 'running';
|
||||
if (progress.status === 'running') {
|
||||
get('#search-selection-status').textContent = 'Deferring ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
} else if (progress.status === 'complete') {
|
||||
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||
progress.queued.length + ' deferred · ' + progress.failed.length + ' need retry.' :
|
||||
progress.queued.length + ' added to Later.';
|
||||
restore();
|
||||
if (!progress.failed.length) plan.cancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
releaseProcessor = batchFactory({
|
||||
capacity:() => plan.limit,
|
||||
owner:getOwner,
|
||||
journalName:'search-release-batch',
|
||||
queueFailureReason:'assigned but release planning could not be confirmed',
|
||||
autoMount:false,
|
||||
claim:item => plan.prepare(item),
|
||||
queue:async (confirmed, context) => {
|
||||
const issuePath = 'api/v1/repos/' + confirmed.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(confirmed.number);
|
||||
const releasePlan = await fetchJson(issuePath + '/release-plan', {
|
||||
method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'},
|
||||
body:JSON.stringify({milestone_id:context.milestone_id, due_date:context.due_date || null}),
|
||||
});
|
||||
if (releasePlan?.number !== confirmed.number ||
|
||||
releasePlan?.milestone?.id !== context.milestone_id ||
|
||||
(context.due_date && releasePlan?.due_date !== context.due_date)) {
|
||||
throw new Error('Issue release plan was not confirmed.');
|
||||
}
|
||||
return 'queued';
|
||||
},
|
||||
onProgress:progress => {
|
||||
get('#confirm-search-release').disabled = progress.status === 'running';
|
||||
if (progress.status === 'running') {
|
||||
get('#search-release-summary').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
} else if (progress.status === 'complete') {
|
||||
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||
progress.queued.length + ' planned · ' + progress.failed.length + ' need retry.' :
|
||||
progress.queued.length + ' planned for ' + (releaseContext?.milestone_title || 'release') + '.';
|
||||
get('#resume-search-batch').hidden = releaseProcessor.pending() === 0;
|
||||
closeReleaseReview();
|
||||
if (!progress.failed.length) plan.cancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
function estimateValues() {
|
||||
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
|
||||
[input.dataset.searchBatchEstimate, Number(input.value)]
|
||||
));
|
||||
}
|
||||
function closeEstimateReview() {
|
||||
get('#search-batch-estimate-review').hidden = true;
|
||||
get('#search-batch-actions').hidden = !plan.snapshot().active;
|
||||
}
|
||||
function openEstimateReview(items) {
|
||||
const planning = todayWork.planning();
|
||||
const planned = Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
|
||||
const remaining = Math.max(0, planning.capacity_minutes - planned);
|
||||
get('#search-batch-estimate-summary').textContent = remaining +
|
||||
' min remain. Estimate before assigning.';
|
||||
get('#search-batch-estimate-list').innerHTML = items.map(item => {
|
||||
const id = String(item.repository || '') + '#' + String(item.number || '');
|
||||
const estimate = planning.estimates?.[id] || '';
|
||||
return '<div class="search-batch-estimate-row"><div><span class="small">' + escapeHtml(id) +
|
||||
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div><label class="small">' +
|
||||
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
|
||||
escapeAttribute(estimate) + '" data-search-batch-estimate="' + escapeAttribute(id) +
|
||||
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes" /> min</label></div>';
|
||||
}).join('');
|
||||
get('#search-batch-actions').hidden = true;
|
||||
get('#search-batch-estimate-review').hidden = false;
|
||||
document.querySelector('[data-search-batch-estimate]')?.focus();
|
||||
}
|
||||
function closeReleaseReview() {
|
||||
get('#search-release-review').hidden = true;
|
||||
get('#search-batch-actions').hidden = !plan.snapshot().active;
|
||||
}
|
||||
async function openReleaseReview() {
|
||||
const items = plan.snapshot().items;
|
||||
const repository = plan.releaseRepository();
|
||||
if (!items.length) return;
|
||||
if (!repository) {
|
||||
get('#search-selection-status').textContent = 'Select issues from one repository to plan a release.';
|
||||
return;
|
||||
}
|
||||
get('#plan-selected-search-results').disabled = true;
|
||||
get('#search-selection-status').textContent = 'Loading release milestones…';
|
||||
try {
|
||||
const milestones = await fetchJson(
|
||||
'api/v1/repos/' + repository.split('/').map(encodeURIComponent).join('/') + '/milestones',
|
||||
{headers:{Accept:'application/json'}}
|
||||
);
|
||||
const open = (Array.isArray(milestones) ? milestones : []).filter(item => item?.state !== 'closed');
|
||||
get('#search-release-milestone').innerHTML = '<option value="">Choose milestone</option>' + open.map(item =>
|
||||
'<option value="' + Number(item.id) + '">' + escapeHtml(item.title) + '</option>'
|
||||
).join('');
|
||||
get('#search-release-list').innerHTML = items.map(item => '<div class="search-release-row"><span>' +
|
||||
escapeHtml(item.repository + ' #' + item.number) + '</span><strong>' +
|
||||
escapeHtml(item.title || 'Untitled work') + '</strong></div>').join('');
|
||||
get('#search-release-summary').textContent = items.length + ' issues in ' + repository + '. Assignment starts only after confirmation.';
|
||||
get('#search-batch-actions').hidden = true;
|
||||
get('#search-release-review').hidden = false;
|
||||
get('#search-release-milestone').focus();
|
||||
} catch (error) {
|
||||
get('#search-selection-status').textContent = error?.message || 'Milestones could not be loaded. Try again.';
|
||||
} finally {
|
||||
get('#plan-selected-search-results').disabled = false;
|
||||
}
|
||||
}
|
||||
get('#select-search-results').addEventListener('click', () => plan.start());
|
||||
get('#cancel-search-selection').addEventListener('click', () => plan.cancel());
|
||||
get('#queue-selected-search-results').addEventListener('click', () => processor.run(plan.snapshot().items));
|
||||
get('#resume-search-batch').addEventListener('click', () => processor.resume());
|
||||
get('#queue-selected-search-results').addEventListener('click', () => {
|
||||
const items = plan.snapshot().items;
|
||||
const capacity = todayWork.planning().capacity_minutes;
|
||||
if (Number.isInteger(capacity) && capacity > 0) openEstimateReview(items);
|
||||
else processor.run(items);
|
||||
});
|
||||
get('#defer-selected-search-results').addEventListener('click', event => {
|
||||
const items = plan.snapshot().items;
|
||||
if (!items.length) return;
|
||||
laterPicker?.open({ items, confirm:until => () => laterProcessor.run(
|
||||
items, {}, { until:new Date(until).toISOString() }
|
||||
) }, event.currentTarget, 'search-batch');
|
||||
});
|
||||
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
|
||||
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
|
||||
get('#confirm-search-release').addEventListener('click', () => {
|
||||
const milestoneSelect = get('#search-release-milestone');
|
||||
const milestoneId = Number(milestoneSelect.value);
|
||||
if (!Number.isInteger(milestoneId) || milestoneId < 1) {
|
||||
get('#search-release-summary').textContent = 'Choose an open milestone.';
|
||||
return;
|
||||
}
|
||||
const selectedOption = milestoneSelect.options?.[milestoneSelect.selectedIndex];
|
||||
const milestoneTitle = selectedOption?.textContent || selectedOption?.text || 'release';
|
||||
const dueDay = get('#search-release-due-date').value;
|
||||
releaseContext = {
|
||||
milestone_id:milestoneId, milestone_title:milestoneTitle,
|
||||
due_date:dueDay ? dueDay + 'T23:59:59Z' : null,
|
||||
};
|
||||
releaseProcessor.run(plan.snapshot().items, {}, releaseContext);
|
||||
});
|
||||
get('#cancel-search-batch-estimates').addEventListener('click', closeEstimateReview);
|
||||
get('#confirm-search-batch-estimates').addEventListener('click', () =>
|
||||
processor.run(plan.snapshot().items, estimateValues())
|
||||
);
|
||||
get('#resume-search-batch').addEventListener('click', () => {
|
||||
if (processor.pending()) return processor.resume();
|
||||
if (laterProcessor.pending()) return laterProcessor.resume();
|
||||
return releaseProcessor.resume();
|
||||
});
|
||||
get('#open-palette').addEventListener('click', () => restore());
|
||||
get('#cmd-results').addEventListener('change', event => {
|
||||
const index = event.target?.dataset?.searchSelect;
|
||||
@@ -117,7 +308,7 @@
|
||||
if (result) plan.toggle(result);
|
||||
});
|
||||
function restore() {
|
||||
const pending = processor.pending();
|
||||
const pending = processor.pending() + laterProcessor.pending() + releaseProcessor.pending();
|
||||
get('#resume-search-batch').hidden = pending === 0;
|
||||
get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
|
||||
}
|
||||
@@ -131,7 +322,7 @@
|
||||
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
|
||||
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
|
||||
}
|
||||
return { plan, processor, restore, resultHtml };
|
||||
return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
|
||||
};
|
||||
|
||||
return createSearchBatchPlan;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v101';
|
||||
const CACHE = 'stackchain-dashboard-shell-v102';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -14,14 +14,17 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/conversation-action-hydrator.js',
|
||||
BASE + 'static/security-center.js',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/saved-searches.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/search-defer.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/unfiled-draft-sync.js',
|
||||
BASE + 'static/shared-image-capture.js',
|
||||
BASE + 'static/draft-filing-session.js',
|
||||
BASE + 'static/draft-capacity-dialog.js',
|
||||
@@ -63,7 +66,10 @@ const SHELL = [
|
||||
BASE + 'static/search-batch-plan.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/comment-actions.js',
|
||||
BASE + 'static/issue-evidence-review.js',
|
||||
BASE + 'static/issue-evidence-editor.js',
|
||||
BASE + 'static/issue-attachment.js',
|
||||
BASE + 'static/issue-filing-review.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
@@ -94,6 +100,7 @@ const SHELL = [
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/mention-composer.js',
|
||||
BASE + 'static/push-notifications.js',
|
||||
BASE + 'static/issue-filing-receipt.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
const OPTIONAL_FEATURES = [
|
||||
@@ -114,17 +121,28 @@ async function acceptSharedContent(request) {
|
||||
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
|
||||
let marker = '';
|
||||
await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {});
|
||||
if (images.length > 1) marker = 'multiple';
|
||||
else if (images.length === 1) {
|
||||
const image = images[0];
|
||||
if (!SHARED_IMAGE_TYPES.has(String(image.type || '')) || image.size > MAX_SHARED_IMAGE_BYTES) {
|
||||
if (images.length > 5) marker = 'multiple';
|
||||
else if (images.length > 0) {
|
||||
const supported = images.every(image =>
|
||||
SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES
|
||||
);
|
||||
if (!supported) {
|
||||
marker = 'unsupported';
|
||||
} else {
|
||||
} else if (images.length === 1) {
|
||||
const image = images[0];
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
});
|
||||
marker = 'image';
|
||||
} else {
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
|
||||
attachments:images.map(image => ({
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
})),
|
||||
});
|
||||
marker = 'images';
|
||||
}
|
||||
}
|
||||
const target = new URL(BASE, self.location.origin);
|
||||
@@ -315,7 +333,7 @@ async function flushAndNotify() {
|
||||
body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' :
|
||||
needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
||||
tag: 'stackchain-delivery-' + receipt.id,
|
||||
data: { route: receipt.route },
|
||||
data: receipt.url ? { url: receipt.url } : { route: receipt.route },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -435,8 +453,29 @@ async function openWorkRoute(route) {
|
||||
return client.focus();
|
||||
}
|
||||
|
||||
async function openCanonicalIssueUrl(rawUrl) {
|
||||
let target;
|
||||
try { target = new URL(rawUrl); } catch (_error) { return; }
|
||||
if (
|
||||
target.origin !== self.location.origin
|
||||
|| !/^\/git\/[^/]+\/[^/]+\/issues\/\d+$/.test(target.pathname)
|
||||
|| target.search || target.hash
|
||||
) return;
|
||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (!client) return self.clients.openWindow(target.href);
|
||||
if (client.navigate) await client.navigate(target.href);
|
||||
return client.focus();
|
||||
}
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
const route = String(event.notification.data?.route || '');
|
||||
const issueUrl = String(event.notification.data?.url || '');
|
||||
if (issueUrl) {
|
||||
event.notification.close();
|
||||
event.waitUntil(openCanonicalIssueUrl(issueUrl));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'protect-today') {
|
||||
const protectRoute = String(event.notification.data?.protectRoute || route);
|
||||
if (
|
||||
|
||||
@@ -10,11 +10,23 @@
|
||||
|
||||
async function consume({marker, store, restore, status = () => {}}) {
|
||||
if (!marker) return false;
|
||||
if (marker !== 'image') {
|
||||
if (!['image', 'images'].includes(marker)) {
|
||||
status('Share one PNG, JPEG, or WebP screenshot.');
|
||||
return false;
|
||||
}
|
||||
const value = await store?.get(RECORD_ID);
|
||||
const attachments = Array.isArray(value?.attachments) ? value.attachments : null;
|
||||
if (marker === 'images') {
|
||||
if (!attachments?.length || attachments.length > 5 || attachments.some(attachment =>
|
||||
!attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')))) {
|
||||
status('The shared screenshots are unavailable. Share them again.');
|
||||
return false;
|
||||
}
|
||||
restore(attachments);
|
||||
await store.delete(RECORD_ID);
|
||||
status(attachments.length + ' shared screenshots ready to file with this issue.');
|
||||
return true;
|
||||
}
|
||||
if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
|
||||
status('The shared screenshot is unavailable. Share it again.');
|
||||
return false;
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
function withFilingEstimate(draft, value) {
|
||||
draft.estimateMinutes = Number(value) || null;
|
||||
return draft;
|
||||
}
|
||||
|
||||
function unfiledDraftSummary(item) {
|
||||
const plan = item.filingPlan || {};
|
||||
const ready = Boolean(plan.repository);
|
||||
const startsToday = plan.completionIntent === 'create-and-start';
|
||||
const evidenceCount = Number(item.attachmentCount || (item.hasAttachment ? 1 : 0));
|
||||
const evidence = evidenceCount ? `${evidenceCount} screenshot${evidenceCount === 1 ? '' : 's'}` : 'No screenshots';
|
||||
return {
|
||||
label:ready ? 'Ready to file' : 'Needs filing',
|
||||
repository:plan.repository || '', ready,
|
||||
action:ready ? (startsToday ? 'Review & start' : 'Review & file') : 'Choose repository',
|
||||
details:[startsToday ? 'Create & start' : 'Create issue',
|
||||
plan.estimateMinutes ? `${plan.estimateMinutes} min` : '', evidence].filter(Boolean).join(' · '),
|
||||
};
|
||||
}
|
||||
|
||||
function unfiledSavedMessage(item) {
|
||||
return item.repository ? 'Saved planned Draft. Resume on any signed-in device.' :
|
||||
'Saved to Drafts. Choose a repository when you’re ready to file it.';
|
||||
}
|
||||
|
||||
function applyIssueFilingMode(qs, enabled) {
|
||||
qs('#create-issue-filing').hidden = !enabled;
|
||||
qs('.create-issue-capture-actions').hidden = false;
|
||||
qs('#file-new-issue').hidden = enabled;
|
||||
qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work';
|
||||
}
|
||||
|
||||
function createUnfiledCaptures({
|
||||
storage,
|
||||
attachmentStore = null,
|
||||
@@ -8,6 +40,8 @@ function createUnfiledCaptures({
|
||||
maxItems = 20,
|
||||
}) {
|
||||
const storageKey = 'stackchain.unfiled-issues.v1';
|
||||
const listeners = new Set();
|
||||
const onChange = (id, removed) => listeners.forEach(listener => listener(id, removed));
|
||||
|
||||
function read() {
|
||||
try {
|
||||
@@ -35,6 +69,56 @@ function createUnfiledCaptures({
|
||||
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
|
||||
}
|
||||
|
||||
function filingPlan(value = {}, remote = false) {
|
||||
const get = (local, wire) => value?.[remote ? wire : local];
|
||||
const repository = String(get('repository', 'repository') || '').trim();
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return null;
|
||||
const labelIds = Array.from(new Set((Array.isArray(get('labelIds', 'label_ids')) ?
|
||||
get('labelIds', 'label_ids') : []).filter(id => Number.isInteger(id) && id > 0))).slice(0, 20);
|
||||
const plan = {repository, labelIds};
|
||||
const milestoneId = Number(get('milestoneId', 'milestone_id'));
|
||||
if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId;
|
||||
const dueDate = String(get('dueDate', 'due_date') || '');
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) plan.dueDate = dueDate;
|
||||
const bounded = (local, wire, limit) => {
|
||||
const text = String(get(local, wire) || '').trim();
|
||||
if (text) plan[local] = text.slice(0, limit);
|
||||
};
|
||||
bounded('templateName', 'template_name', 80);
|
||||
bounded('templateId', 'template_id', 80);
|
||||
bounded('capturedBody', 'captured_body', 10000);
|
||||
if (get('unassigned', 'unassigned') === true) plan.unassigned = true;
|
||||
const assignee = String(get('assignee', 'assignee') || '').trim();
|
||||
if (!plan.unassigned && /^[A-Za-z0-9_.-]+$/.test(assignee)) {
|
||||
plan.assignee = assignee;
|
||||
bounded('assigneeName', 'assignee_name', 255);
|
||||
}
|
||||
const estimateMinutes = Number(get('estimateMinutes', 'estimate_minutes'));
|
||||
if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) {
|
||||
plan.estimateMinutes = estimateMinutes;
|
||||
}
|
||||
const completionIntent = String(get('completionIntent', 'completion_intent') || '');
|
||||
if (['create', 'create-and-start'].includes(completionIntent)) plan.completionIntent = completionIntent;
|
||||
return plan;
|
||||
}
|
||||
|
||||
function exportFilingPlan(plan) {
|
||||
if (!plan) return null;
|
||||
return {
|
||||
repository:plan.repository, label_ids:plan.labelIds,
|
||||
...(plan.milestoneId ? {milestone_id:plan.milestoneId} : {}),
|
||||
...(plan.dueDate ? {due_date:plan.dueDate} : {}),
|
||||
...(plan.templateName ? {template_name:plan.templateName} : {}),
|
||||
...(plan.templateId ? {template_id:plan.templateId} : {}),
|
||||
...(plan.capturedBody ? {captured_body:plan.capturedBody} : {}),
|
||||
...(plan.unassigned ? {unassigned:true} : {}),
|
||||
...(plan.assignee ? {assignee:plan.assignee} : {}),
|
||||
...(plan.assigneeName ? {assignee_name:plan.assigneeName} : {}),
|
||||
...(plan.estimateMinutes ? {estimate_minutes:plan.estimateMinutes} : {}),
|
||||
...(plan.completionIntent ? {completion_intent:plan.completionIntent} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
@@ -42,19 +126,51 @@ function createUnfiledCaptures({
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
const attachment = note?.attachment;
|
||||
const attachments = note?.attachments;
|
||||
if (Array.isArray(attachments) && attachments.length > 5) {
|
||||
throw new Error('You can attach up to 5 screenshots.');
|
||||
}
|
||||
const validAttachments = Array.isArray(attachments) && attachments.length > 0 &&
|
||||
attachments.every(value => value?.blob && value?.filename &&
|
||||
['image/png', 'image/jpeg', 'image/webp'].includes(String(value?.contentType || '')));
|
||||
if (attachments && !validAttachments) {
|
||||
throw new Error('One or more screenshots are unavailable. Choose them again before saving.');
|
||||
}
|
||||
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
|
||||
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
|
||||
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
|
||||
if (hasAttachment && !attachmentStore) {
|
||||
if ((hasAttachment || validAttachments) && !attachmentStore) {
|
||||
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
||||
}
|
||||
return {title, body, ownerLogin, attachment, hasAttachment};
|
||||
const seenBlockers = new Set();
|
||||
const blockers = (Array.isArray(note?.blockers) ? note.blockers : []).reduce((items, blocker) => {
|
||||
const repository = String(blocker?.repository || '').trim();
|
||||
const number = Number(blocker?.number);
|
||||
const key = repository + '#' + number;
|
||||
if (items.length >= 5 || seenBlockers.has(key) ||
|
||||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
||||
!Number.isInteger(number) || number < 1) return items;
|
||||
seenBlockers.add(key);
|
||||
items.push({repository, number,
|
||||
title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)});
|
||||
return items;
|
||||
}, []);
|
||||
return {
|
||||
title, body, ownerLogin, attachment, attachments, blockers,
|
||||
filingPlan:filingPlan(note),
|
||||
hasAttachment:hasAttachment || validAttachments,
|
||||
attachmentCount:validAttachments ? attachments.length : (hasAttachment ? 1 : 0),
|
||||
};
|
||||
}
|
||||
|
||||
function persist(prepared, existing, removed = null) {
|
||||
const item = {
|
||||
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
|
||||
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
|
||||
savedAt:Number(now()), ...(prepared.hasAttachment ? {
|
||||
hasAttachment:true, attachmentCount:prepared.attachmentCount,
|
||||
} : {}), ...(prepared.blockers.length ? {
|
||||
blockers:prepared.blockers, blockerCount:prepared.blockers.length,
|
||||
} : {}), ...(prepared.filingPlan ? {filingPlan:prepared.filingPlan} : {}),
|
||||
};
|
||||
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
|
||||
const writeItems = () => {
|
||||
@@ -63,10 +179,17 @@ function createUnfiledCaptures({
|
||||
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
onChange(item.id, false);
|
||||
return item;
|
||||
};
|
||||
const stage = prepared.hasAttachment
|
||||
? Promise.resolve(attachmentStore.put(item.id, {
|
||||
? Promise.resolve(attachmentStore.put(item.id, prepared.attachments ? {
|
||||
attachments:prepared.attachments.map(value => ({
|
||||
filename:String(value.filename).slice(0, 255),
|
||||
contentType:String(value.contentType), blob:value.blob,
|
||||
...(String(value.note || '').trim() ? {note:String(value.note).trim().slice(0, 240)} : {}),
|
||||
})),
|
||||
} : {
|
||||
filename:String(prepared.attachment.filename).slice(0, 255),
|
||||
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
|
||||
}))
|
||||
@@ -113,6 +236,7 @@ function createUnfiledCaptures({
|
||||
const removed = items.find(item => item.id === id);
|
||||
if (!removed?.hasAttachment) {
|
||||
write(remaining);
|
||||
onChange(id, true);
|
||||
return true;
|
||||
}
|
||||
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
|
||||
@@ -122,6 +246,7 @@ function createUnfiledCaptures({
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
onChange(id, true);
|
||||
return true;
|
||||
})
|
||||
);
|
||||
@@ -133,10 +258,18 @@ function createUnfiledCaptures({
|
||||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||
throw new Error('Reconnect with the account that saved this capture.');
|
||||
}
|
||||
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
const draft = {...(item.filingPlan || {repository:'', labelIds:[]}), title:item.title, body:item.body,
|
||||
...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {})};
|
||||
if (!item.hasAttachment) return draft;
|
||||
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
|
||||
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
|
||||
if (Array.isArray(attachment?.attachments) && attachment.attachments.length) {
|
||||
if (attachment.attachments.length > 5 || attachment.attachments.some(value =>
|
||||
!value?.blob || !value?.filename || !value?.contentType)) {
|
||||
throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
return {...draft, attachments:attachment.attachments};
|
||||
}
|
||||
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
|
||||
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
@@ -146,7 +279,147 @@ function createUnfiledCaptures({
|
||||
|
||||
function completeResume(id) { return discard(id); }
|
||||
|
||||
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
|
||||
function encodeBytes(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function decodeBytes(value, contentType) {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return new Blob([bytes], {type:contentType});
|
||||
}
|
||||
|
||||
async function exportOwned(login) {
|
||||
const owner = String(login || '').trim();
|
||||
const exported = [];
|
||||
for (const item of read().filter(candidate => candidate.ownerLogin === owner)) {
|
||||
let evidence = [];
|
||||
if (item.hasAttachment) {
|
||||
const stored = await attachmentStore?.get(item.id);
|
||||
const attachments = Array.isArray(stored?.attachments) ? stored.attachments :
|
||||
(stored?.blob ? [stored] : []);
|
||||
if (attachments.length !== Number(item.attachmentCount || 1)) {
|
||||
throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
evidence = await Promise.all(attachments.map(async attachment => ({
|
||||
filename:attachment.filename,
|
||||
content_type:attachment.contentType,
|
||||
...(attachment.note ? {note:attachment.note} : {}),
|
||||
data:encodeBytes(await attachment.blob.arrayBuffer()),
|
||||
})));
|
||||
}
|
||||
exported.push({
|
||||
id:item.id, title:item.title, body:item.body, saved_at:Number(item.savedAt),
|
||||
...(item.filingPlan ? {filing_plan:exportFilingPlan(item.filingPlan)} : {}),
|
||||
...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {}),
|
||||
...(evidence.length ? {evidence} : {}),
|
||||
});
|
||||
}
|
||||
return exported;
|
||||
}
|
||||
|
||||
async function mergeRemote(drafts, login) {
|
||||
const ownerLogin = String(login || '').trim();
|
||||
if (!ownerLogin || !Array.isArray(drafts)) return 0;
|
||||
const existing = read();
|
||||
const known = new Set(existing.map(item => item.id));
|
||||
const imported = [];
|
||||
for (const remote of drafts) {
|
||||
if (!remote || known.has(remote.id) || imported.length + existing.length >= maxItems) continue;
|
||||
const evidence = Array.isArray(remote.evidence) ? remote.evidence : [];
|
||||
if (evidence.length && !attachmentStore) continue;
|
||||
const plan = filingPlan(remote.filing_plan, true);
|
||||
const item = {
|
||||
id:String(remote.id), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''),
|
||||
savedAt:Number(remote.saved_at),
|
||||
...(plan ? {filingPlan:plan} : {}),
|
||||
...(Array.isArray(remote.blockers) && remote.blockers.length ? {
|
||||
blockers:remote.blockers, blockerCount:remote.blockers.length,
|
||||
} : {}),
|
||||
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
|
||||
};
|
||||
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
|
||||
if (evidence.length) {
|
||||
await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({
|
||||
filename:String(entry.filename), contentType:String(entry.content_type),
|
||||
blob:decodeBytes(String(entry.data), String(entry.content_type)),
|
||||
...(entry.note ? {note:String(entry.note)} : {}),
|
||||
}))});
|
||||
}
|
||||
known.add(item.id);
|
||||
imported.push(item);
|
||||
}
|
||||
if (imported.length) write(imported.concat(existing));
|
||||
return imported.length;
|
||||
}
|
||||
|
||||
async function reconcileRemote(drafts, login) {
|
||||
const ownerLogin = String(login || '').trim();
|
||||
if (!ownerLogin || !Array.isArray(drafts)) return 0;
|
||||
const existing = read();
|
||||
const retained = existing.filter(item => item.ownerLogin !== ownerLogin);
|
||||
const reconciled = [];
|
||||
const stagedEvidence = new Map();
|
||||
for (const remote of drafts) {
|
||||
if (!remote || reconciled.length + retained.length >= maxItems) continue;
|
||||
const evidence = Array.isArray(remote.evidence) ? remote.evidence : [];
|
||||
if (evidence.length && !attachmentStore) {
|
||||
throw new Error('The synchronized screenshots cannot be stored on this device.');
|
||||
}
|
||||
const plan = filingPlan(remote.filing_plan, true);
|
||||
const item = {
|
||||
id:String(remote.id || ''), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''),
|
||||
savedAt:Number(remote.saved_at),
|
||||
...(plan ? {filingPlan:plan} : {}),
|
||||
...(Array.isArray(remote.blockers) && remote.blockers.length ? {
|
||||
blockers:remote.blockers, blockerCount:remote.blockers.length,
|
||||
} : {}),
|
||||
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
|
||||
};
|
||||
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
|
||||
if (evidence.length) {
|
||||
stagedEvidence.set(item.id, {attachments:evidence.map(entry => ({
|
||||
filename:String(entry.filename), contentType:String(entry.content_type),
|
||||
blob:decodeBytes(String(entry.data), String(entry.content_type)),
|
||||
...(entry.note ? {note:String(entry.note)} : {}),
|
||||
}))});
|
||||
}
|
||||
reconciled.push(item);
|
||||
}
|
||||
const evidenceIds = new Set(reconciled.filter(item => item.hasAttachment).map(item => item.id));
|
||||
const affectedIds = new Set(stagedEvidence.keys());
|
||||
for (const item of existing) if (item.ownerLogin === ownerLogin && item.hasAttachment) affectedIds.add(item.id);
|
||||
const previousEvidence = new Map();
|
||||
for (const id of affectedIds) previousEvidence.set(id, await attachmentStore?.get(id));
|
||||
try {
|
||||
for (const [id, value] of stagedEvidence) await attachmentStore.put(id, value);
|
||||
for (const item of existing) {
|
||||
if (item.ownerLogin === ownerLogin && item.hasAttachment && !evidenceIds.has(item.id)) {
|
||||
await attachmentStore?.delete(item.id);
|
||||
}
|
||||
}
|
||||
write(reconciled.concat(retained));
|
||||
} catch (error) {
|
||||
for (const [id, value] of previousEvidence) {
|
||||
await Promise.resolve(value ? attachmentStore?.put(id, value) : attachmentStore?.delete(id)).catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return reconciled.length;
|
||||
}
|
||||
|
||||
return {list, capacity, save, replaceOldest, discard, resume, completeResume,
|
||||
exportOwned, mergeRemote, reconcileRemote, currentLogin:getCurrentLogin,
|
||||
subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createUnfiledCaptures;
|
||||
module.exports.summary = unfiledDraftSummary;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
(function (root, factory) {
|
||||
const createUnfiledDraftSync = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createUnfiledDraftSync;
|
||||
if (root) root.createUnfiledDraftSync = createUnfiledDraftSync;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function createUnfiledDraftSync(options, mountedFetch) {
|
||||
if (mountedFetch) return createUnfiledDraftSync.mount(options, mountedFetch);
|
||||
const {
|
||||
captures, fetchJson, storage, getLogin = () => '', onState = () => {}, maxConflictRetries = 2,
|
||||
createConflictId = () => 'conflict-' + (globalThis.crypto?.randomUUID?.() || Date.now().toString(36)),
|
||||
} = options;
|
||||
const key = 'stackchain.unfiled-draft-sync.v1';
|
||||
let inFlight = null;
|
||||
let rerun = false;
|
||||
let queuedOwner = '';
|
||||
|
||||
function read(login) {
|
||||
try {
|
||||
const all = JSON.parse(storage?.getItem(key) || '{}');
|
||||
const state = all?.[login];
|
||||
return {
|
||||
all:all && typeof all === 'object' ? all : {},
|
||||
known:new Set(Array.isArray(state?.known) ? state.known : []),
|
||||
deleted:new Set(Array.isArray(state?.deleted) ? state.deleted : []),
|
||||
base:state?.base && typeof state.base === 'object' ? state.base : {},
|
||||
};
|
||||
} catch (_error) {
|
||||
return {all:{}, known:new Set(), deleted:new Set(), base:{}};
|
||||
}
|
||||
}
|
||||
|
||||
function write(login, state) {
|
||||
state.all[login] = {known:[...state.known], deleted:[...state.deleted], base:state.base};
|
||||
storage?.setItem(key, JSON.stringify(state.all));
|
||||
}
|
||||
|
||||
function publish(status, message, details = {}) {
|
||||
onState({status, ...(message ? {message} : {}), ...details});
|
||||
const visibleStatus = globalThis.document?.querySelector?.('#my-work-action-status');
|
||||
if (message && visibleStatus) visibleStatus.textContent = message;
|
||||
}
|
||||
|
||||
function sameDrafts(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
async function fingerprint(draft) {
|
||||
if (!draft) return null;
|
||||
const text = JSON.stringify(draft);
|
||||
if (globalThis.crypto?.subtle && globalThis.TextEncoder) {
|
||||
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
||||
return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
let first = 2166136261, second = 3335557771;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
first = Math.imul(first ^ text.charCodeAt(index), 16777619);
|
||||
second = Math.imul(second ^ text.charCodeAt(index), 2246822519);
|
||||
}
|
||||
return text.length + ':' + (first >>> 0).toString(16) + (second >>> 0).toString(16);
|
||||
}
|
||||
|
||||
async function reconcile(captures, drafts, owner) {
|
||||
if (typeof captures.reconcileRemote === 'function') {
|
||||
await captures.reconcileRemote(drafts, owner);
|
||||
return;
|
||||
}
|
||||
const finalIds = new Set(drafts.map(item => item.id));
|
||||
for (const item of await captures.exportOwned(owner)) {
|
||||
if (!finalIds.has(item.id)) await captures.discard(item.id);
|
||||
}
|
||||
await captures.mergeRemote(drafts, owner);
|
||||
}
|
||||
|
||||
async function runSync(login) {
|
||||
const owner = String(login || '').trim();
|
||||
if (!owner) return false;
|
||||
publish('syncing', 'Syncing Drafts…');
|
||||
const state = read(owner);
|
||||
try {
|
||||
const remote = await fetchJson('api/v1/unfiled-drafts');
|
||||
const remoteDrafts = Array.isArray(remote?.drafts) ? remote.drafts : [];
|
||||
const local = await captures.exportOwned(owner);
|
||||
const merged = [];
|
||||
const localById = new Map(local.map(item => [item.id, item]));
|
||||
const remoteById = new Map(remoteDrafts.map(item => [item.id, item]));
|
||||
const ids = new Set([...state.known, ...localById.keys(), ...remoteById.keys()]);
|
||||
let preservedConflict = false;
|
||||
const usedIds = new Set(ids);
|
||||
for (const id of ids) {
|
||||
const localDraft = state.deleted.has(id) ? null : localById.get(id);
|
||||
const remoteDraft = remoteById.get(id);
|
||||
const localHash = await fingerprint(localDraft);
|
||||
const remoteHash = await fingerprint(remoteDraft);
|
||||
const baseHash = state.base[id] || null;
|
||||
const localChanged = localHash !== baseHash;
|
||||
const remoteChanged = remoteHash !== baseHash;
|
||||
if (localChanged && remoteChanged && localHash !== remoteHash) {
|
||||
preservedConflict = true;
|
||||
if (remoteDraft) merged.push(remoteDraft);
|
||||
if (localDraft) {
|
||||
let conflictId = String(createConflictId()).slice(0, 100);
|
||||
for (let suffix = 2; usedIds.has(conflictId); suffix += 1) {
|
||||
conflictId = (String(createConflictId()).slice(0, 95) + '-' + suffix).slice(0, 100);
|
||||
}
|
||||
usedIds.add(conflictId);
|
||||
const suffix = ' (conflict copy)';
|
||||
merged.push({...localDraft, id:conflictId,
|
||||
title:(String(localDraft.title).slice(0, 255 - suffix.length) + suffix)});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const selected = localChanged ? localDraft : (remoteChanged ? remoteDraft : (localDraft || remoteDraft));
|
||||
if (selected) merged.push(selected);
|
||||
}
|
||||
merged.sort((left, right) => Number(right.saved_at) - Number(left.saved_at));
|
||||
|
||||
let finalSnapshot = remote;
|
||||
if (!sameDrafts(merged, remoteDrafts)) {
|
||||
finalSnapshot = await fetchJson('api/v1/unfiled-drafts', {
|
||||
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({revision:Number(remote.revision || 0), drafts:merged}),
|
||||
});
|
||||
}
|
||||
await reconcile(captures, finalSnapshot.drafts || [], owner);
|
||||
state.known = new Set((finalSnapshot.drafts || []).map(item => item.id));
|
||||
state.deleted.clear();
|
||||
state.base = {};
|
||||
for (const item of finalSnapshot.drafts || []) state.base[item.id] = await fingerprint(item);
|
||||
write(owner, state);
|
||||
publish(preservedConflict ? 'conflict-preserved' : 'ready', preservedConflict
|
||||
? 'Draft changed on two devices · both versions were saved.'
|
||||
: 'Drafts synced across devices.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.status !== 409) {
|
||||
publish('pending', 'Saved on this device · sync pending');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function sync(login) {
|
||||
const owner = String(login || '').trim();
|
||||
if (!owner) return Promise.resolve(false);
|
||||
if (inFlight) {
|
||||
rerun = true;
|
||||
queuedOwner = owner;
|
||||
return inFlight;
|
||||
}
|
||||
inFlight = (async () => {
|
||||
let result = false;
|
||||
let conflictRetries = 0;
|
||||
let activeOwner = owner;
|
||||
do {
|
||||
rerun = false;
|
||||
try {
|
||||
result = await runSync(activeOwner);
|
||||
} catch (error) {
|
||||
if (error?.status !== 409) throw error;
|
||||
if (conflictRetries >= maxConflictRetries) {
|
||||
publish('conflict', 'Draft sync needs attention. Retry when your devices finish saving.', {retryable:true});
|
||||
throw error;
|
||||
}
|
||||
conflictRetries += 1;
|
||||
rerun = true;
|
||||
}
|
||||
if (queuedOwner) {
|
||||
if (queuedOwner !== activeOwner) conflictRetries = 0;
|
||||
activeOwner = queuedOwner;
|
||||
queuedOwner = '';
|
||||
rerun = true;
|
||||
}
|
||||
} while (rerun);
|
||||
return result;
|
||||
})().finally(() => { inFlight = null; });
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
async function remove(id, login) {
|
||||
const owner = String(login || '').trim();
|
||||
if (!owner) return false;
|
||||
const state = read(owner);
|
||||
state.deleted.add(String(id));
|
||||
write(owner, state);
|
||||
await captures.discard(String(id));
|
||||
return sync(owner);
|
||||
}
|
||||
|
||||
function retry() {
|
||||
return sync(getLogin());
|
||||
}
|
||||
|
||||
captures.subscribe?.((id, removed) => {
|
||||
const login = getLogin();
|
||||
if (login) void (removed ? remove(id, login) : sync(login)).catch(() => {});
|
||||
});
|
||||
|
||||
return {sync, remove, retry};
|
||||
}
|
||||
createUnfiledDraftSync.mount = (captures, fetchJson) => {
|
||||
const getLogin = captures.currentLogin;
|
||||
const retryButton = globalThis.document?.querySelector?.('#retry-unfiled-draft-sync');
|
||||
const controller = createUnfiledDraftSync({
|
||||
captures, fetchJson, storage:globalThis.localStorage, getLogin,
|
||||
onState:state => { if (retryButton) retryButton.hidden = !state.retryable; },
|
||||
});
|
||||
retryButton?.addEventListener?.('click', async () => {
|
||||
retryButton.disabled = true;
|
||||
try { await controller.retry(); } catch (_error) { /* state remains actionable */ }
|
||||
finally { retryButton.disabled = false; }
|
||||
});
|
||||
const connect = () => {
|
||||
const login = getLogin();
|
||||
if (login) void controller.sync(login).catch(() => {});
|
||||
else globalThis.setTimeout(connect, 250);
|
||||
};
|
||||
globalThis.setTimeout(connect, 0);
|
||||
globalThis.addEventListener?.('online', connect);
|
||||
return controller;
|
||||
};
|
||||
return createUnfiledDraftSync;
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'update', 'later', 'draft'];
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
@@ -27,7 +27,7 @@
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
}
|
||||
if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
|
||||
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
|
||||
const number = positiveInteger(parts[5]);
|
||||
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
|
||||
@@ -38,11 +38,12 @@
|
||||
const notificationId = positiveInteger(item.notification_id);
|
||||
return notificationId ? '#/my-work/update/' + notificationId : '';
|
||||
}
|
||||
if (!['issue', 'pull', 'review'].includes(item?.kind)) return '';
|
||||
const kind = item?.is_filed && !item?.is_assigned ? 'filed' : item?.kind;
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(kind)) return '';
|
||||
const repository = String(item.repository || '').split('/');
|
||||
const number = positiveInteger(item.number);
|
||||
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
|
||||
return '#/my-work/' + item.kind + '/' + repository.join('/') + '/' + number;
|
||||
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number;
|
||||
}
|
||||
|
||||
function sameRoute(item, route) {
|
||||
@@ -50,7 +51,8 @@
|
||||
if (route.kind === 'update') {
|
||||
return Number(item.notification_id) === route.notification_id;
|
||||
}
|
||||
const itemKind = item.is_review ? 'review' : item.kind;
|
||||
const itemKind = item.is_review ? 'review' :
|
||||
(item.is_filed && !item.is_assigned ? 'filed' : item.kind);
|
||||
return itemKind === route.kind && item.repository === route.repository &&
|
||||
Number(item.number) === route.number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
playwright==1.55.0
|
||||
@@ -22,18 +22,21 @@ COMMONJS_BROWSER_BRANCH = re.compile(
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||
"issue-capture": ("static/create-issue-sheet.js", "static/update-follow-up.js"),
|
||||
"issue-capture": (
|
||||
"static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/commands.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||
"static/assign-and-start.js", "static/queue-today.js",
|
||||
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
"static/search-batch-plan.js",
|
||||
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||
"static/issue-attachment.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
|
||||
+143
-8
@@ -367,6 +367,7 @@ async def repository_access(repository: str) -> dict | None:
|
||||
|
||||
WORK_SEARCHES = {
|
||||
"issue": ("assigned=true", "issues", None),
|
||||
"filed": ("created=true", "issues", "created_by_me"),
|
||||
"pull": ("assigned=true", "pulls", "assigned_to_me"),
|
||||
"review": ("review_requested=true", "pulls", "review_requested"),
|
||||
}
|
||||
@@ -738,8 +739,20 @@ def _page_metadata(result: dict) -> dict:
|
||||
|
||||
|
||||
async def issues() -> WorkItems:
|
||||
result = await work_page("issue")
|
||||
return WorkItems(result["items"], {"issue": _page_metadata(result)})
|
||||
assigned, filed = await asyncio.gather(work_page("issue"), work_page("filed"))
|
||||
merged: dict[int, dict] = {}
|
||||
for result in (assigned, filed):
|
||||
for issue in result["items"]:
|
||||
identity = issue.get("id")
|
||||
if identity not in merged:
|
||||
merged[identity] = {**issue, "work_reasons": []}
|
||||
for reason in issue.get("work_reasons", []):
|
||||
if reason not in merged[identity]["work_reasons"]:
|
||||
merged[identity]["work_reasons"].append(reason)
|
||||
return WorkItems(
|
||||
list(merged.values()),
|
||||
{"issue": _page_metadata(assigned), "filed": _page_metadata(filed)},
|
||||
)
|
||||
|
||||
|
||||
def _safe_web_url(value: Any) -> str:
|
||||
@@ -1400,6 +1413,51 @@ async def repo_labels(repository: str) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
async def repo_issue_templates(repository: str) -> list[dict]:
|
||||
"""Return a small allowlisted view of repository issue templates."""
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/issue_templates", headers=_auth()
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if payload is None:
|
||||
return []
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea issue templates response was not a list")
|
||||
templates = []
|
||||
for item in payload:
|
||||
if len(templates) >= 20:
|
||||
break
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
name = name.strip()[:80]
|
||||
identifier = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:80]
|
||||
labels = []
|
||||
for label in item.get("labels", []) if isinstance(item.get("labels"), list) else []:
|
||||
if not isinstance(label, str):
|
||||
continue
|
||||
clean = label.strip()[:80]
|
||||
if clean and clean not in labels:
|
||||
labels.append(clean)
|
||||
if len(labels) >= 20:
|
||||
break
|
||||
scalar = lambda key, limit: (
|
||||
item.get(key, "")[:limit] if isinstance(item.get(key), str) else ""
|
||||
)
|
||||
templates.append({
|
||||
"id": identifier or f"template-{len(templates) + 1}",
|
||||
"name": name,
|
||||
"about": scalar("about", 240).strip(),
|
||||
"title": scalar("title", 255),
|
||||
"body": scalar("content", 9000).strip(),
|
||||
"labels": labels,
|
||||
})
|
||||
return templates
|
||||
|
||||
|
||||
async def repo_milestones(repository: str) -> list[dict]:
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/milestones",
|
||||
@@ -1424,12 +1482,14 @@ async def create_issue(
|
||||
repository: str,
|
||||
title: str,
|
||||
body: str,
|
||||
assignee: str,
|
||||
assignee: str | None,
|
||||
label_ids: list[int] | None = None,
|
||||
milestone_id: int | None = None,
|
||||
due_date: str | None = None,
|
||||
) -> dict:
|
||||
payload: dict = {"title": title, "body": body, "assignee": assignee}
|
||||
payload: dict = {"title": title, "body": body}
|
||||
if assignee is not None:
|
||||
payload["assignee"] = assignee
|
||||
if label_ids:
|
||||
payload["labels"] = label_ids
|
||||
if milestone_id is not None:
|
||||
@@ -1452,8 +1512,11 @@ async def create_issue(
|
||||
for item in assignees
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
]
|
||||
if assignee not in confirmed_assignees:
|
||||
raise ValueError("Gitea did not confirm issue self-assignment")
|
||||
expected_assignees = [assignee] if assignee is not None else []
|
||||
if confirmed_assignees != expected_assignees:
|
||||
raise ValueError(
|
||||
"Gitea did not confirm self-assignment or exact issue assignment"
|
||||
)
|
||||
labels_value = issue.get("labels")
|
||||
labels = labels_value if isinstance(labels_value, list) else []
|
||||
milestone_value = issue.get("milestone")
|
||||
@@ -1969,6 +2032,58 @@ async def update_assigned_issue_milestone(
|
||||
}
|
||||
|
||||
|
||||
async def update_assigned_issue_release_plan(
|
||||
repository: str, number: int, milestone_id: int, due_date: str | None
|
||||
) -> dict:
|
||||
path = f"repos/{repository}/issues/{number}"
|
||||
login, issue = await _current_login_and_target(path)
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or isinstance(issue.get("pull_request"), dict)
|
||||
or not _login_in_users(login, issue.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("assigned issue not found")
|
||||
|
||||
selected = next(
|
||||
(item for item in await repo_milestones(repository) if item["id"] == milestone_id),
|
||||
None,
|
||||
)
|
||||
if selected is None:
|
||||
raise ValueError("Unknown open repository milestone")
|
||||
|
||||
payload: dict = {"milestone": milestone_id}
|
||||
if due_date is not None:
|
||||
payload["due_date"] = due_date
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/{path}", headers=_auth(), json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
milestone_value = confirmed.get("milestone") if isinstance(confirmed, dict) else None
|
||||
normalized = (
|
||||
{"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
|
||||
)
|
||||
expected_due_date = due_date if due_date is not None else issue.get("due_date")
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or normalized != selected
|
||||
or confirmed.get("due_date") != expected_due_date
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the issue release plan")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"milestone": normalized,
|
||||
"due_date": confirmed.get("due_date"),
|
||||
}
|
||||
|
||||
|
||||
async def issue_conversation_page(
|
||||
repository: str,
|
||||
number: int,
|
||||
@@ -2245,19 +2360,22 @@ async def resolve_work_route(
|
||||
"title": detail.get("title", ""),
|
||||
"url": detail.get("url", ""),
|
||||
}
|
||||
if repository is None or number is None or kind not in {"issue", "pull", "review"}:
|
||||
if repository is None or number is None or kind not in {"issue", "filed", "pull", "review"}:
|
||||
raise WorkRouteUnavailableError("Work identity is missing")
|
||||
|
||||
target_path = "issues" if kind == "issue" else "pulls"
|
||||
target_path = "issues" if kind in {"issue", "filed"} else "pulls"
|
||||
login, target = await _current_login_and_target(
|
||||
f"repos/{repository}/{target_path}/{number}"
|
||||
)
|
||||
assigned = _login_in_users(login, target.get("assignees"))
|
||||
requested = _login_in_users(login, target.get("requested_reviewers"))
|
||||
author = target.get("user") if isinstance(target.get("user"), dict) else {}
|
||||
authored = author.get("login") == login
|
||||
eligible = (
|
||||
target.get("state") == "open"
|
||||
and (
|
||||
(kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned)
|
||||
or (kind == "filed" and not isinstance(target.get("pull_request"), dict) and authored)
|
||||
or (kind == "pull" and assigned)
|
||||
or (kind == "review" and requested)
|
||||
)
|
||||
@@ -2272,6 +2390,11 @@ async def resolve_work_route(
|
||||
"state": "open",
|
||||
"url": _safe_web_url(target.get("html_url")),
|
||||
**({"is_review": True, "work_reasons": ["review_requested"]} if kind == "review" else {}),
|
||||
**({
|
||||
"is_filed": True,
|
||||
"is_assigned": assigned,
|
||||
"work_reasons": ["created_by_me"],
|
||||
} if kind == "filed" else {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -2286,6 +2409,18 @@ async def is_assigned_issue(repository: str, number: int) -> bool:
|
||||
)
|
||||
|
||||
|
||||
async def is_authored_issue(repository: str, number: int) -> bool:
|
||||
login, issue = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
author = issue.get("user") if isinstance(issue.get("user"), dict) else {}
|
||||
return (
|
||||
issue.get("state") == "open"
|
||||
and not isinstance(issue.get("pull_request"), dict)
|
||||
and author.get("login") == login
|
||||
)
|
||||
|
||||
|
||||
async def pull_requests() -> WorkItems:
|
||||
assigned, review_requested = await asyncio.gather(
|
||||
work_page("pull"),
|
||||
|
||||
+360
-9
@@ -57,6 +57,8 @@ from src.push_notifications import (
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
|
||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||
from src.suggestion_engine import compute
|
||||
from src.later_store import LaterStore
|
||||
@@ -617,6 +619,82 @@ class LaterOperationBatch(BaseModel):
|
||||
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
class SavedSearchView(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
|
||||
name: str = Field(min_length=1, max_length=60)
|
||||
query: str = Field(min_length=2, max_length=200)
|
||||
kind: Literal["all", "issue", "pull"] = "all"
|
||||
state: Literal["all", "open", "closed"] = "all"
|
||||
repository: str = Field(default="", max_length=200)
|
||||
|
||||
|
||||
class SavedSearchCollection(BaseModel):
|
||||
revision: int = Field(ge=0)
|
||||
views: list[SavedSearchView] = Field(max_length=20)
|
||||
|
||||
|
||||
class UnfiledDraftBlocker(BaseModel):
|
||||
repository: str = Field(min_length=3, max_length=200)
|
||||
number: int = Field(ge=1)
|
||||
title: str = Field(default="", max_length=255)
|
||||
|
||||
|
||||
class UnfiledDraftEvidence(BaseModel):
|
||||
filename: str = Field(min_length=1, max_length=255)
|
||||
content_type: Literal["image/png", "image/jpeg", "image/webp"]
|
||||
note: str = Field(default="", max_length=240)
|
||||
data: str = Field(max_length=14_000_000)
|
||||
|
||||
|
||||
class UnfiledDraftFilingPlan(BaseModel):
|
||||
repository: str = Field(
|
||||
min_length=3,
|
||||
max_length=200,
|
||||
pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
|
||||
)
|
||||
label_ids: list[PositiveInt] = 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}$", max_length=10)
|
||||
template_name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
template_id: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
captured_body: str | None = Field(default=None, min_length=1, max_length=10_000)
|
||||
assignee: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.-]+$", max_length=255)
|
||||
assignee_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
unassigned: bool = False
|
||||
estimate_minutes: int | None = Field(default=None, ge=5, le=1440)
|
||||
completion_intent: Literal["create", "create-and-start"] | None = None
|
||||
|
||||
@field_validator("due_date")
|
||||
@classmethod
|
||||
def validate_filing_due_date(cls, value: str | None) -> str | None:
|
||||
if value is not None:
|
||||
datetime.strptime(value, "%Y-%m-%d")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_consistent_owner_intent(self):
|
||||
if self.unassigned and self.assignee is not None:
|
||||
raise ValueError("assignee and unassigned cannot be requested together")
|
||||
if self.assignee is None and self.assignee_name is not None:
|
||||
raise ValueError("assignee name requires an assignee")
|
||||
return self
|
||||
|
||||
|
||||
class UnfiledDraft(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
saved_at: int = Field(ge=0)
|
||||
filing_plan: UnfiledDraftFilingPlan | None = None
|
||||
blockers: list[UnfiledDraftBlocker] = Field(default_factory=list, max_length=5)
|
||||
evidence: list[UnfiledDraftEvidence] = Field(default_factory=list, max_length=5)
|
||||
|
||||
|
||||
class UnfiledDraftCollection(BaseModel):
|
||||
revision: int = Field(ge=0)
|
||||
drafts: list[UnfiledDraft] = Field(max_length=20)
|
||||
|
||||
|
||||
class NotificationLaterRequest(BaseModel):
|
||||
wake_at: str = Field(min_length=1, max_length=100)
|
||||
|
||||
@@ -699,6 +777,10 @@ def _validate_binary_attachment(filename: str, content_type: str, content: bytes
|
||||
class IssueCreation(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
unassigned: bool = False
|
||||
assignee: str | None = Field(
|
||||
default=None, pattern=r"^[A-Za-z0-9_.-]+$", max_length=255
|
||||
)
|
||||
label_ids: list[int] = Field(default_factory=list, max_length=20)
|
||||
milestone_id: PositiveInt | None = None
|
||||
due_date: str | None = Field(
|
||||
@@ -720,6 +802,12 @@ class IssueCreation(BaseModel):
|
||||
def strip_issue_body(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_one_owner_intent(self):
|
||||
if self.unassigned and self.assignee is not None:
|
||||
raise ValueError("assignee and unassigned cannot be requested together")
|
||||
return self
|
||||
|
||||
@field_validator("due_date")
|
||||
@classmethod
|
||||
def validate_due_date(cls, value: str | None) -> str | None:
|
||||
@@ -763,6 +851,15 @@ class IssueMilestoneUpdate(BaseModel):
|
||||
milestone_id: PositiveInt | None = None
|
||||
|
||||
|
||||
class IssueReleasePlanUpdate(BaseModel):
|
||||
milestone_id: PositiveInt
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class IssueHandoff(BaseModel):
|
||||
recipient: str = Field(
|
||||
min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$"
|
||||
@@ -924,6 +1021,7 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||
id=i["id"], number=i["number"], title=i["title"], state=i["state"],
|
||||
labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)],
|
||||
assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, dict)],
|
||||
work_reasons=[reason for reason in (i.get("work_reasons") or []) if reason == "created_by_me"],
|
||||
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
|
||||
updated_at=i.get("updated_at") or "",
|
||||
due_date=i.get("due_date") if isinstance(i.get("due_date"), str) else None,
|
||||
@@ -963,13 +1061,14 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||
|
||||
|
||||
def _normalize_work_items(stream: str, items: list[dict]) -> list[dict]:
|
||||
if stream == "issue":
|
||||
if stream in {"issue", "filed"}:
|
||||
return [
|
||||
Issue(
|
||||
id=item["id"], number=item["number"], title=item["title"],
|
||||
state=item["state"],
|
||||
labels=[label.get("name", "") for label in (item.get("labels") or []) if isinstance(label, dict)],
|
||||
assignees=[assignee.get("login", "") for assignee in (item.get("assignees") or []) if isinstance(assignee, dict)],
|
||||
work_reasons=[reason for reason in (item.get("work_reasons") or []) if reason == "created_by_me"],
|
||||
repository=item["repository"].get("full_name", "") if isinstance(item.get("repository"), dict) else "",
|
||||
updated_at=item.get("updated_at") or "",
|
||||
due_date=item.get("due_date") if isinstance(item.get("due_date"), str) else None,
|
||||
@@ -1174,7 +1273,7 @@ async def require_operator_session(request: Request, call_next):
|
||||
async def prevent_live_api_caching(request, call_next):
|
||||
response = await call_next(request)
|
||||
path = dashboard_auth.application_path(request)
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||
path.startswith("/api/v1/repos/")
|
||||
and path.endswith("/review")
|
||||
) or path.startswith("/api/v1/notifications") or (
|
||||
@@ -2105,6 +2204,18 @@ def _later_store() -> LaterStore:
|
||||
)
|
||||
|
||||
|
||||
def _saved_search_store() -> SavedSearchStore:
|
||||
return SavedSearchStore(
|
||||
os.getenv("STACKCHAIN_SAVED_SEARCH_DB", str(_state_dir / "saved-searches.sqlite3"))
|
||||
)
|
||||
|
||||
|
||||
def _unfiled_draft_store() -> UnfiledDraftStore:
|
||||
return UnfiledDraftStore(
|
||||
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3"))
|
||||
)
|
||||
|
||||
|
||||
async def _confirmed_login() -> str:
|
||||
try:
|
||||
user = await asyncio.wait_for(
|
||||
@@ -2120,6 +2231,92 @@ async def _confirmed_login() -> str:
|
||||
return login.strip().lower()
|
||||
|
||||
|
||||
@app.get("/api/v1/saved-searches")
|
||||
async def get_saved_searches(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
snapshot = await asyncio.to_thread(_saved_search_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Saved Search synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return snapshot
|
||||
|
||||
|
||||
@app.put("/api/v1/saved-searches")
|
||||
async def replace_saved_searches(payload: SavedSearchCollection):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_saved_search_store().replace,
|
||||
login,
|
||||
payload.revision,
|
||||
[view.model_dump() for view in payload.views],
|
||||
)
|
||||
except SavedSearchConflict as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "Saved searches changed on another device.",
|
||||
"snapshot": exc.snapshot,
|
||||
},
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Saved Search synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/unfiled-drafts")
|
||||
async def get_unfiled_drafts(response: Response):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
snapshot = await asyncio.to_thread(_unfiled_draft_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Draft synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return snapshot
|
||||
|
||||
|
||||
@app.put("/api/v1/unfiled-drafts")
|
||||
async def replace_unfiled_drafts(payload: UnfiledDraftCollection):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_unfiled_draft_store().replace,
|
||||
login,
|
||||
payload.revision,
|
||||
[draft.model_dump() for draft in payload.drafts],
|
||||
)
|
||||
except UnfiledDraftConflict as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": "Drafts changed on another device.",
|
||||
"snapshot": exc.snapshot,
|
||||
},
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Draft synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/today")
|
||||
async def get_today_plan():
|
||||
login = await _confirmed_login()
|
||||
@@ -2858,7 +3055,7 @@ async def comment_on_global_search_preview(
|
||||
|
||||
@app.get("/api/v1/work-route")
|
||||
async def resolve_work_route(
|
||||
kind: Literal["issue", "pull", "review", "update"] = Query(),
|
||||
kind: Literal["issue", "filed", "pull", "review", "update"] = Query(),
|
||||
repository: str | None = Query(default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"),
|
||||
number: int | None = Query(default=None, gt=0),
|
||||
notification_id: int | None = Query(default=None, gt=0),
|
||||
@@ -2885,7 +3082,7 @@ async def resolve_work_route(
|
||||
|
||||
@app.get("/api/v1/work/{stream}")
|
||||
async def paged_work(
|
||||
stream: Literal["issue", "pull", "review"],
|
||||
stream: Literal["issue", "filed", "pull", "review"],
|
||||
page: int = Query(ge=2, le=100),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
@@ -4190,13 +4387,24 @@ async def handoff_assigned_issue(
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
|
||||
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
|
||||
async def assigned_issue_detail(
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
access: Literal["assigned", "filed"] = Query(default="assigned"),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_assigned_issue():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
authorized = await (
|
||||
gitea_proxy.is_authored_issue(repository, number)
|
||||
if access == "filed"
|
||||
else gitea_proxy.is_assigned_issue(repository, number)
|
||||
)
|
||||
if not authorized:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await gitea_proxy.issue_detail(repository, number)
|
||||
detail = await gitea_proxy.issue_detail(repository, number)
|
||||
return {**detail, "read_only": True} if access == "filed" else detail
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
@@ -4374,6 +4582,85 @@ async def update_assigned_issue_milestone(
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/release-plan")
|
||||
async def update_assigned_issue_release_plan(
|
||||
update: IssueReleasePlanUpdate,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.update_assigned_issue_release_plan(
|
||||
repository, number, update.milestone_id, update.due_date
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
except ValueError as exc:
|
||||
if str(exc) == "Unknown open repository milestone":
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
return JSONResponse(
|
||||
{"error": "The release plan could not be confirmed. Your selection is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The release plan could not be updated. Your selection is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issue-filing-metadata")
|
||||
async def repository_issue_filing_metadata(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_metadata():
|
||||
if await gitea_proxy.repository_access(repository) is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
names = ("labels", "milestones", "templates")
|
||||
loaders = (
|
||||
gitea_proxy.repo_labels(repository),
|
||||
gitea_proxy.repo_milestones(repository),
|
||||
gitea_proxy.repo_issue_templates(repository),
|
||||
)
|
||||
results = await asyncio.gather(*loaders, return_exceptions=True)
|
||||
messages = {
|
||||
"labels": "Labels could not be loaded.",
|
||||
"milestones": "Milestones could not be loaded.",
|
||||
"templates": "Issue types could not be loaded.",
|
||||
}
|
||||
payload = {}
|
||||
for name, result in zip(names, results):
|
||||
if isinstance(result, BaseException):
|
||||
payload[name] = {
|
||||
"available": False,
|
||||
"items": [],
|
||||
"error": messages[name],
|
||||
}
|
||||
else:
|
||||
payload[name] = {"available": True, "items": result}
|
||||
return JSONResponse(payload, headers={"Cache-Control": "no-store"})
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
load_metadata(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Issue filing details could not be loaded. Your draft is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1", "Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/milestones")
|
||||
async def repository_milestones(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
@@ -4418,6 +4705,57 @@ async def repository_labels(owner: str, repo: str):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issue-templates")
|
||||
async def repository_issue_templates(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_templates():
|
||||
if await gitea_proxy.repository_access(repository) is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return await gitea_proxy.repo_issue_templates(repository)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(load_templates(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Issue types could not be loaded. Blank issue creation is still available."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issue-assignees")
|
||||
async def repository_issue_assignees(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_assignees():
|
||||
if await gitea_proxy.repository_access(repository) is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
candidates = [
|
||||
item
|
||||
for item in await gitea_proxy.issue_handoff_candidates(repository)
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("login"), str)
|
||||
and re.fullmatch(r"[A-Za-z0-9_.-]+", item["login"])
|
||||
][:25]
|
||||
return JSONResponse(candidates, headers={"Cache-Control": "no-store"})
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
load_assignees(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Owners could not be loaded. You can still assign the issue to yourself."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/issues", status_code=201)
|
||||
async def create_assigned_issue(
|
||||
creation: IssueCreation,
|
||||
@@ -4434,6 +4772,17 @@ async def create_assigned_issue(
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not login or accessible is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
assignee = None if creation.unassigned else login
|
||||
if not creation.unassigned and creation.assignee and creation.assignee != login:
|
||||
eligible = {
|
||||
item["login"]
|
||||
for item in await gitea_proxy.issue_handoff_candidates(repository)
|
||||
}
|
||||
if creation.assignee not in eligible:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Selected owner is no longer eligible"
|
||||
)
|
||||
assignee = creation.assignee
|
||||
if creation.label_ids:
|
||||
available_labels = await gitea_proxy.repo_labels(repository)
|
||||
valid_label_ids = {
|
||||
@@ -4459,13 +4808,13 @@ async def create_assigned_issue(
|
||||
repository,
|
||||
creation.title,
|
||||
creation.body,
|
||||
login,
|
||||
assignee,
|
||||
creation.label_ids,
|
||||
creation.milestone_id,
|
||||
creation.due_date,
|
||||
)
|
||||
return await gitea_proxy.create_issue(
|
||||
repository, creation.title, creation.body, login, creation.label_ids
|
||||
repository, creation.title, creation.body, assignee, creation.label_ids
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -4477,6 +4826,8 @@ async def create_assigned_issue(
|
||||
repository,
|
||||
creation.title,
|
||||
creation.body,
|
||||
creation.assignee,
|
||||
creation.unassigned,
|
||||
tuple(creation.label_ids),
|
||||
creation.milestone_id,
|
||||
creation.due_date,
|
||||
|
||||
@@ -29,6 +29,7 @@ class Issue(BaseModel):
|
||||
state: str
|
||||
labels: list[str] = []
|
||||
assignees: list[str] = []
|
||||
work_reasons: list[str] = []
|
||||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
due_date: str | None = None
|
||||
|
||||
@@ -7,6 +7,7 @@ SESSION_BODY_LIMIT = 16 * 1024
|
||||
API_MUTATION_BODY_LIMIT = 64 * 1024
|
||||
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
|
||||
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
|
||||
UNFILED_DRAFT_SYNC_BODY_LIMIT = 17 * 1024 * 1024
|
||||
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
||||
|
||||
|
||||
@@ -15,6 +16,8 @@ def request_body_limit(method: str, path: str) -> int | None:
|
||||
normalized_method = method.upper()
|
||||
if normalized_method == "POST" and path == "/api/v1/session":
|
||||
return SESSION_BODY_LIMIT
|
||||
if normalized_method == "PUT" and path == "/api/v1/unfiled-drafts":
|
||||
return UNFILED_DRAFT_SYNC_BODY_LIMIT
|
||||
if (
|
||||
normalized_method == "POST"
|
||||
and (
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Durable, account-scoped saved Search views."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_VIEW_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
|
||||
|
||||
class SavedSearchConflict(ValueError):
|
||||
"""Raised when a client attempts to replace a stale collection."""
|
||||
|
||||
def __init__(self, snapshot: dict):
|
||||
super().__init__("saved searches changed on another device")
|
||||
self.snapshot = snapshot
|
||||
|
||||
|
||||
class SavedSearchStore:
|
||||
def __init__(self, path: str | Path, *, limit: int = 20, timeout: float = 1.0):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(self.path, timeout=self.timeout) as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS saved_searches (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
views TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
return {"revision": 0, "views": []} if row is None else {
|
||||
"revision": int(row[0]), "views": json.loads(row[1])
|
||||
}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, views FROM saved_searches WHERE login = ?",
|
||||
(self._login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
def _normalize(self, views: list[dict]) -> list[dict]:
|
||||
if not isinstance(views, list):
|
||||
raise ValueError("views must be a list")
|
||||
if len(views) > self.limit:
|
||||
raise ValueError(f"saved searches are limited to {self.limit}")
|
||||
normalized = []
|
||||
seen = set()
|
||||
for raw in views:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("saved search must be an object")
|
||||
view_id = raw.get("id")
|
||||
if not isinstance(view_id, str) or not _VIEW_ID.fullmatch(view_id):
|
||||
raise ValueError("id is invalid")
|
||||
if view_id in seen:
|
||||
raise ValueError("saved search ids must be unique")
|
||||
name = raw.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("name is required")
|
||||
name = name.strip()
|
||||
if len(name) > 60:
|
||||
raise ValueError("name must be at most 60 characters")
|
||||
query = raw.get("query")
|
||||
if not isinstance(query, str) or not 2 <= len(query.strip()) <= 200:
|
||||
raise ValueError("query must be between 2 and 200 characters")
|
||||
kind = raw.get("kind", "all")
|
||||
state = raw.get("state", "all")
|
||||
if kind not in {"all", "issue", "pull"}:
|
||||
raise ValueError("kind is invalid")
|
||||
if state not in {"all", "open", "closed"}:
|
||||
raise ValueError("state is invalid")
|
||||
repository = raw.get("repository", "")
|
||||
if not isinstance(repository, str) or (repository and not _REPOSITORY.fullmatch(repository)):
|
||||
raise ValueError("repository is invalid")
|
||||
seen.add(view_id)
|
||||
normalized.append({
|
||||
"id": view_id,
|
||||
"name": name,
|
||||
"query": query.strip(),
|
||||
"kind": kind,
|
||||
"state": state,
|
||||
"repository": repository,
|
||||
})
|
||||
return normalized
|
||||
|
||||
def replace(self, login: str, expected_revision: int, views: list[dict]) -> dict:
|
||||
login = self._login(login)
|
||||
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||
raise ValueError("revision is invalid")
|
||||
normalized = self._normalize(views)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, views FROM saved_searches WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current = self._snapshot(row)
|
||||
if current["revision"] != expected_revision:
|
||||
raise SavedSearchConflict(current)
|
||||
revision = expected_revision + 1
|
||||
connection.execute(
|
||||
"INSERT INTO saved_searches(login, revision, views) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, views=excluded.views",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
return {"revision": revision, "views": normalized}
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Durable, account-scoped unfiled issue drafts and ordered evidence."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
|
||||
|
||||
class UnfiledDraftConflict(ValueError):
|
||||
"""Raised when a client attempts to replace a stale draft collection."""
|
||||
|
||||
def __init__(self, snapshot: dict):
|
||||
super().__init__("unfiled drafts changed on another device")
|
||||
self.snapshot = snapshot
|
||||
|
||||
|
||||
class UnfiledDraftStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 20,
|
||||
max_total_bytes: int = 12 * 1024 * 1024,
|
||||
timeout: float = 1.0,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.max_total_bytes = max_total_bytes
|
||||
self.timeout = timeout
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(self.path, timeout=self.timeout) as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS unfiled_drafts (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
drafts TEXT NOT NULL
|
||||
)"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self.path, timeout=self.timeout)
|
||||
|
||||
@staticmethod
|
||||
def _login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
return {"revision": 0, "drafts": []} if row is None else {
|
||||
"revision": int(row[0]), "drafts": json.loads(row[1])
|
||||
}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?",
|
||||
(self._login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
@staticmethod
|
||||
def _filing_plan(raw: object) -> dict | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("filing plan is invalid")
|
||||
repository = raw.get("repository")
|
||||
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
||||
raise ValueError("filing repository is invalid")
|
||||
label_ids = raw.get("label_ids", [])
|
||||
if not isinstance(label_ids, list) or len(label_ids) > 20:
|
||||
raise ValueError("filing labels are invalid")
|
||||
if any(not isinstance(item, int) or isinstance(item, bool) or item < 1 for item in label_ids):
|
||||
raise ValueError("filing labels are invalid")
|
||||
if len(set(label_ids)) != len(label_ids):
|
||||
raise ValueError("filing labels are invalid")
|
||||
plan = {"repository": repository, "label_ids": label_ids}
|
||||
for key in ("milestone_id", "estimate_minutes"):
|
||||
value = raw.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
upper = 1440 if key == "estimate_minutes" else None
|
||||
lower = 5 if key == "estimate_minutes" else 1
|
||||
if (
|
||||
not isinstance(value, int)
|
||||
or isinstance(value, bool)
|
||||
or value < lower
|
||||
or (upper is not None and value > upper)
|
||||
):
|
||||
raise ValueError(f"filing {key.replace('_', ' ')} is invalid")
|
||||
plan[key] = value
|
||||
due_date = raw.get("due_date")
|
||||
if due_date is not None:
|
||||
try:
|
||||
datetime.strptime(due_date, "%Y-%m-%d")
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("filing due date is invalid") from error
|
||||
plan["due_date"] = due_date
|
||||
for key, limit in {
|
||||
"template_name": 80,
|
||||
"template_id": 80,
|
||||
"captured_body": 10_000,
|
||||
"assignee_name": 255,
|
||||
}.items():
|
||||
value = raw.get(key)
|
||||
if value is not None:
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
raise ValueError(f"filing {key.replace('_', ' ')} is invalid")
|
||||
plan[key] = value
|
||||
unassigned = raw.get("unassigned", False)
|
||||
if not isinstance(unassigned, bool):
|
||||
raise ValueError("filing owner intent is invalid")
|
||||
assignee = raw.get("assignee")
|
||||
if unassigned and assignee is not None:
|
||||
raise ValueError("filing owner intent is invalid")
|
||||
if unassigned:
|
||||
plan["unassigned"] = True
|
||||
elif assignee is not None:
|
||||
if not isinstance(assignee, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+", assignee):
|
||||
raise ValueError("filing assignee is invalid")
|
||||
plan["assignee"] = assignee
|
||||
completion_intent = raw.get("completion_intent")
|
||||
if completion_intent is not None:
|
||||
if completion_intent not in {"create", "create-and-start"}:
|
||||
raise ValueError("filing completion intent is invalid")
|
||||
plan["completion_intent"] = completion_intent
|
||||
return plan
|
||||
|
||||
def _normalize(self, drafts: list[dict]) -> list[dict]:
|
||||
if not isinstance(drafts, list):
|
||||
raise ValueError("drafts must be a list")
|
||||
if len(drafts) > self.limit:
|
||||
raise ValueError(f"unfiled drafts are limited to {self.limit}")
|
||||
normalized = []
|
||||
seen = set()
|
||||
decoded_total = 0
|
||||
for raw in drafts:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("draft must be an object")
|
||||
draft_id = raw.get("id")
|
||||
if not isinstance(draft_id, str) or not _DRAFT_ID.fullmatch(draft_id):
|
||||
raise ValueError("draft id is invalid")
|
||||
if draft_id in seen:
|
||||
raise ValueError("draft ids must be unique")
|
||||
title = raw.get("title")
|
||||
body = raw.get("body", "")
|
||||
saved_at = raw.get("saved_at")
|
||||
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 255:
|
||||
raise ValueError("title is invalid")
|
||||
if not isinstance(body, str) or len(body) > 10_000:
|
||||
raise ValueError("body is invalid")
|
||||
if not isinstance(saved_at, int) or isinstance(saved_at, bool) or saved_at < 0:
|
||||
raise ValueError("saved_at is invalid")
|
||||
filing_plan = self._filing_plan(raw.get("filing_plan"))
|
||||
blockers = raw.get("blockers", [])
|
||||
if not isinstance(blockers, list) or len(blockers) > 5:
|
||||
raise ValueError("blockers are invalid")
|
||||
clean_blockers = []
|
||||
for blocker in blockers:
|
||||
repository = blocker.get("repository") if isinstance(blocker, dict) else None
|
||||
number = blocker.get("number") if isinstance(blocker, dict) else None
|
||||
blocker_title = blocker.get("title", "") if isinstance(blocker, dict) else ""
|
||||
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
||||
raise ValueError("blocker repository is invalid")
|
||||
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||
raise ValueError("blocker number is invalid")
|
||||
if not isinstance(blocker_title, str) or len(blocker_title) > 255:
|
||||
raise ValueError("blocker title is invalid")
|
||||
clean_blockers.append({"repository": repository, "number": number, "title": blocker_title})
|
||||
evidence = raw.get("evidence", [])
|
||||
if not isinstance(evidence, list) or len(evidence) > 5:
|
||||
raise ValueError("evidence is invalid")
|
||||
clean_evidence = []
|
||||
for item in evidence:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("evidence is invalid")
|
||||
filename = item.get("filename")
|
||||
content_type = item.get("content_type")
|
||||
note = item.get("note", "")
|
||||
data = item.get("data")
|
||||
if not isinstance(filename, str) or not filename or len(filename) > 255:
|
||||
raise ValueError("evidence filename is invalid")
|
||||
if content_type not in _CONTENT_TYPES:
|
||||
raise ValueError("evidence content type is invalid")
|
||||
if not isinstance(note, str) or len(note) > 240:
|
||||
raise ValueError("evidence note is invalid")
|
||||
if not isinstance(data, str):
|
||||
raise ValueError("evidence data is invalid")
|
||||
try:
|
||||
decoded_total += len(base64.b64decode(data, validate=True))
|
||||
except (binascii.Error, ValueError) as error:
|
||||
raise ValueError("evidence data must be valid base64") from error
|
||||
if decoded_total > self.max_total_bytes:
|
||||
raise ValueError("synchronized evidence is too large")
|
||||
clean_evidence.append({
|
||||
"filename": filename,
|
||||
"content_type": content_type,
|
||||
**({"note": note} if note else {}),
|
||||
"data": data,
|
||||
})
|
||||
seen.add(draft_id)
|
||||
normalized.append({
|
||||
"id": draft_id,
|
||||
"title": title.strip(),
|
||||
"body": body,
|
||||
"saved_at": saved_at,
|
||||
**({"filing_plan": filing_plan} if filing_plan else {}),
|
||||
**({"blockers": clean_blockers} if clean_blockers else {}),
|
||||
**({"evidence": clean_evidence} if clean_evidence else {}),
|
||||
})
|
||||
return normalized
|
||||
|
||||
def replace(self, login: str, expected_revision: int, drafts: list[dict]) -> dict:
|
||||
login = self._login(login)
|
||||
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||
raise ValueError("revision is invalid")
|
||||
normalized = self._normalize(drafts)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, drafts FROM unfiled_drafts WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current = self._snapshot(row)
|
||||
if current["revision"] != expected_revision:
|
||||
raise UnfiledDraftConflict(current)
|
||||
revision = expected_revision + 1
|
||||
connection.execute(
|
||||
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, drafts=excluded.drafts",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
return {"revision": revision, "drafts": normalized}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Deterministic Gitea double used by the release-artifact browser journey."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
REPOSITORY = {
|
||||
"id": 7,
|
||||
"name": "mobile",
|
||||
"full_name": "acme/mobile",
|
||||
"description": "Mobile filing acceptance repository",
|
||||
"html_url": "http://127.0.0.1/acme/mobile",
|
||||
"owner": {"login": "acme"},
|
||||
}
|
||||
USER = {"id": 1, "login": "timmy", "full_name": "Timmy"}
|
||||
|
||||
|
||||
class FakeGiteaServer(ThreadingHTTPServer):
|
||||
def __init__(self, address: tuple[str, int]):
|
||||
super().__init__(address, FakeGiteaHandler)
|
||||
self.created_issues: list[dict] = []
|
||||
|
||||
|
||||
class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
server: FakeGiteaServer
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
def _json(self, status: int, payload: object, **headers: str) -> None:
|
||||
body = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
for name, value in headers.items():
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/api/v1/user":
|
||||
self._json(200, USER)
|
||||
elif path == "/api/v1/user/repos":
|
||||
self._json(200, [REPOSITORY], **{"X-Total-Count": "1"})
|
||||
elif path in {"/api/v1/repos/acme/mobile", "/api/v1/repos/acme/mobile/"}:
|
||||
self._json(200, REPOSITORY)
|
||||
elif path == "/api/v1/repos/search":
|
||||
self._json(200, {"data": [REPOSITORY], "ok": True})
|
||||
elif path.startswith("/api/v1/"):
|
||||
self._json(200, [], **{"X-Total-Count": "0"})
|
||||
else:
|
||||
self._json(404, {"message": "not found"})
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = urlsplit(self.path).path
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||
if path != "/api/v1/repos/acme/mobile/issues":
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
self.server.created_issues.append(payload)
|
||||
assignee = payload.get("assignee")
|
||||
self._json(
|
||||
201,
|
||||
{
|
||||
"id": 101,
|
||||
"number": 41,
|
||||
"title": payload.get("title", ""),
|
||||
"body": payload.get("body", ""),
|
||||
"state": "open",
|
||||
"html_url": "http://127.0.0.1/acme/mobile/issues/41",
|
||||
"assignees": [{"login": assignee}] if assignee else [],
|
||||
"labels": [],
|
||||
"milestone": None,
|
||||
"due_date": payload.get("due_date"),
|
||||
"updated_at": "2026-08-15T12:00:00Z",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("release artifact browser journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import Page, expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TITLE = "Offline artifact journey 863"
|
||||
BODY = "Captured on a phone, retained offline, delivered exactly once."
|
||||
ACCESS_TOKEN = "artifact-browser-access-token-863"
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def wait_for_health(url: str, process: subprocess.Popen[str]) -> None:
|
||||
deadline = time.monotonic() + 20
|
||||
tls = ssl._create_unverified_context()
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
stdout, stderr = process.communicate()
|
||||
raise AssertionError(f"release server exited early\nstdout:\n{stdout}\nstderr:\n{stderr}")
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=0.5, context=tls) as response:
|
||||
if response.status == 200:
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
raise AssertionError("release server did not become healthy")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def release_server(archive: Path, tmp_path: Path, gitea_url: str):
|
||||
release_root = tmp_path / "release"
|
||||
release_root.mkdir()
|
||||
with tarfile.open(archive, "r:gz") as bundle:
|
||||
bundle.extractall(release_root, filter="data")
|
||||
assert (release_root / "release-manifest.json").is_file()
|
||||
assert not (release_root / "tests").exists(), "journey must run the packaged artifact, not checkout code"
|
||||
|
||||
port = free_port()
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir()
|
||||
key_file = tmp_path / "localhost.key"
|
||||
certificate_file = tmp_path / "localhost.crt"
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
||||
"-keyout", str(key_file), "-out", str(certificate_file), "-days", "1",
|
||||
"-subj", "/CN=127.0.0.1", "-addext", "subjectAltName=IP:127.0.0.1",
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
origin = f"https://127.0.0.1:{port}"
|
||||
env = {
|
||||
**os.environ,
|
||||
"PYTHONPATH": str(release_root),
|
||||
"GITEA_URL": gitea_url,
|
||||
"GITEA_TOKEN": "deterministic-e2e-token",
|
||||
"STACKCHAIN_DASHBOARD_AUTH_MODE": "operator",
|
||||
"STACKCHAIN_DASHBOARD_ACCESS_TOKEN": ACCESS_TOKEN,
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET": "artifact-browser-independent-session-secret-863",
|
||||
"STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN": origin,
|
||||
"STACKCHAIN_STATE_DIR": str(tmp_path / "state"),
|
||||
}
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable, "-m", "uvicorn", "src.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(port),
|
||||
"--ssl-keyfile", str(key_file), "--ssl-certfile", str(certificate_file),
|
||||
],
|
||||
cwd=release_root,
|
||||
env=env,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
wait_for_health(origin + "/healthz", process)
|
||||
yield origin
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.communicate()
|
||||
|
||||
|
||||
def indexed_issue_records(page: Page) -> list[dict]:
|
||||
return page.evaluate(
|
||||
"""async () => {
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('stackchain-background-outbox-v1', 1);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return await new Promise((resolve, reject) => {
|
||||
const request = db.transaction('issues', 'readonly').objectStore('issues').getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_reload(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": 390, "height": 844},
|
||||
service_workers="allow",
|
||||
ignore_https_errors=True,
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
|
||||
page.on("response", lambda response: failed_responses.append(
|
||||
f"{response.status} {response.url}"
|
||||
) if response.status >= 400 else None)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
expect(page.locator("#sign-in")).to_be_visible()
|
||||
page.locator('input[name="device_label"]').fill("Release journey phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
new_action = page.locator('[data-mobile-task="new"]')
|
||||
expect(new_action).to_be_visible()
|
||||
service_worker_ready = page.evaluate(
|
||||
"""() => Promise.race([
|
||||
navigator.serviceWorker.ready.then(() => true),
|
||||
new Promise(resolve => setTimeout(() => resolve(false), 10000)),
|
||||
])"""
|
||||
)
|
||||
assert service_worker_ready, (
|
||||
"release artifact did not register its service worker; browser errors=" + repr(browser_errors)
|
||||
+ "; failed responses=" + repr(failed_responses)
|
||||
)
|
||||
if not page.evaluate("() => navigator.serviceWorker.controller !== null"):
|
||||
page.reload(wait_until="networkidle")
|
||||
new_action = page.locator('[data-mobile-task="new"]')
|
||||
assert page.evaluate("() => navigator.serviceWorker.controller !== null")
|
||||
|
||||
new_action.click()
|
||||
page.locator("#create-issue-title").fill(TITLE)
|
||||
page.locator("#create-issue-body").fill(BODY)
|
||||
page.locator("#file-new-issue").click()
|
||||
expect(page.locator("#create-issue-filing")).to_be_visible()
|
||||
page.locator("#create-issue-repository").select_option("acme/mobile")
|
||||
expect(page.locator("#submit-new-issue")).to_be_enabled()
|
||||
|
||||
context.set_offline(True)
|
||||
page.locator("#submit-new-issue").click()
|
||||
expect(page.locator("#issue-filing-review")).to_be_visible()
|
||||
page.locator("#confirm-issue-filing").click()
|
||||
page.wait_for_timeout(1_000)
|
||||
filing_statuses = page.locator(
|
||||
"#my-work-action-status, #create-issue-status, #issue-filing-review-status"
|
||||
).all_inner_texts()
|
||||
assert any(
|
||||
marker in " ".join(filing_statuses).lower()
|
||||
for marker in ("queued", "saved for", "background delivery")
|
||||
), (filing_statuses, browser_errors, failed_responses)
|
||||
|
||||
local_record = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
|
||||
assert [(item["title"], item["body"], item["repository"]) for item in local_record["items"]] == [
|
||||
(TITLE, BODY, "acme/mobile")
|
||||
]
|
||||
durable = indexed_issue_records(page)
|
||||
assert [(item["title"], item["body"], item["repository"]) for item in durable] == [
|
||||
(TITLE, BODY, "acme/mobile")
|
||||
]
|
||||
operation_id = durable[0]["operationId"]
|
||||
assert operation_id and operation_id == local_record["items"][0]["operationId"]
|
||||
assert fake.created_issues == []
|
||||
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
|
||||
durable_after_reload = indexed_issue_records(page)
|
||||
assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)]
|
||||
assert durable_after_reload[0]["operationId"] == operation_id
|
||||
assert fake.created_issues == []
|
||||
|
||||
browser_errors.clear() # Chromium reports expected network errors while the context is offline.
|
||||
failed_responses.clear()
|
||||
context.set_offline(False)
|
||||
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||
for _ in range(80):
|
||||
if fake.created_issues:
|
||||
break
|
||||
page.wait_for_timeout(250)
|
||||
assert fake.created_issues == [{"title": TITLE, "body": BODY, "assignee": "timmy"}]
|
||||
|
||||
for _ in range(40):
|
||||
durable_completion = indexed_issue_records(page)
|
||||
if durable_completion and all(item.get("status") == "sent" for item in durable_completion):
|
||||
break
|
||||
page.wait_for_timeout(100)
|
||||
assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
|
||||
page.reload(wait_until="networkidle")
|
||||
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||
for _ in range(40):
|
||||
completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
|
||||
if completed_local["items"] == []:
|
||||
break
|
||||
page.wait_for_timeout(100)
|
||||
assert completed_local["items"] == []
|
||||
page.wait_for_timeout(500)
|
||||
assert len(fake.created_issues) == 1
|
||||
assert browser_errors == []
|
||||
assert failed_responses == []
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -64,6 +64,38 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_preserves_explicit_no_owner():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'unowned-1',operationId:'unowned-1',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Backlog capture',body:'',labelIds:[],unassigned:true}};
|
||||
const calls=[];const store={{claimNext:async()=>item,complete:async()=>{{item=null;}},release:async()=>{{}},fail:async()=>{{}}}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push({{url,body:options.body||''}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:19,assignees:[]}};}};
|
||||
(async()=>{{await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(calls));}})();
|
||||
"""
|
||||
calls = run_node(script)
|
||||
assert json.loads(calls[1]["body"]) == {
|
||||
"title": "Backlog capture", "body": "", "label_ids": [], "unassigned": True
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_issue_receipt_uses_the_confirmed_canonical_gitea_url():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'unowned-link',operationId:'unowned-link',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Backlog capture',body:'',labelIds:[],unassigned:true}};
|
||||
const store={{claimNext:async()=>item,complete:async()=>{{item=null;}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:19,title:'Backlog capture',assignees:[],url:'https://forge.example/git/o/r/issues/19'}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(result.receipts[0]));}})();
|
||||
"""
|
||||
receipt = run_node(script)
|
||||
|
||||
assert receipt == {
|
||||
"id": "unowned-link",
|
||||
"status": "confirmed",
|
||||
"kind": "issue",
|
||||
"url": "https://forge.example/git/o/r/issues/19",
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -164,6 +196,71 @@ const fetchJson=async(url,options={{}})=>{{
|
||||
assert output["second"]["confirmed"][0]["number"] == 469
|
||||
|
||||
|
||||
def test_closed_app_capture_retries_only_unfinished_blockers_after_creation():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'blocked',operationId:'blocked',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Blocked',body:'',labelIds:[],blockers:[
|
||||
{{repository:'o/api',number:7,title:'API'}},{{repository:'o/web',number:8,title:'Web'}}
|
||||
]}};
|
||||
const calls=[];let secondAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
const body=options.body?JSON.parse(options.body):null;calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||
if(url.endsWith('/issues'))return{{number:42,repository:'o/r'}};
|
||||
if(body.number===8 && secondAttempts++===0){{const e=new Error('offline');e.status=503;throw e;}}
|
||||
return{{number:42,dependencies_available:true,dependencies:[body]}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';try{{await sync.flush();}}catch(e){{first=e.message;}}const checkpoint={{...item}};const result=await sync.flush();process.stdout.write(JSON.stringify({{first,checkpoint,calls,result}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["first"] == "offline"
|
||||
assert output["checkpoint"]["deliveredIssue"]["number"] == 42
|
||||
assert output["checkpoint"]["deliveredBlockers"] == 1
|
||||
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
|
||||
blockers = [call for call in output["calls"] if call["url"].endswith("/blockers")]
|
||||
assert [call["body"]["number"] for call in blockers] == [7, 8, 8]
|
||||
assert output["result"]["confirmed"][0]["number"] == 42
|
||||
|
||||
|
||||
def test_evidence_bundle_retry_resumes_at_failed_image_and_posts_one_ordered_comment():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Journey',body:'',labelIds:[],attachments:[
|
||||
{{filename:'one.png',contentType:'image/png',data:'b25l',note:'First state'}},
|
||||
{{filename:'two.png',contentType:'image/png',data:'dHdv',note:'Tap [Submit](unsafe)'}},
|
||||
{{filename:'three.png',contentType:'image/png',data:'dGhyZWU='}},
|
||||
]}};
|
||||
const calls=[];let twoAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
let filename=null;if(options.body instanceof FormData)filename=options.body.get('file').name;
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename,body:options.body instanceof FormData?null:JSON.parse(options.body)}});
|
||||
if(url.endsWith('/issues'))return{{number:823}};
|
||||
if(url.endsWith('/attachments')){{if(filename==='two.png' && twoAttempts++===0){{const e=new Error('offline');e.status=503;throw e;}}return{{markdown:''}};}}
|
||||
return{{id:9}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';try{{await sync.flush();}}catch(e){{first=e.message;}}const checkpoint={{...item}};await sync.flush();process.stdout.write(JSON.stringify({{first,checkpoint,calls}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["first"] == "offline"
|
||||
uploads = [call for call in output["calls"] if call["filename"]]
|
||||
assert [call["filename"] for call in uploads] == ["one.png", "two.png", "two.png", "three.png"]
|
||||
assert [call["key"] for call in uploads] == [
|
||||
"bundle:attachment-0", "bundle:attachment-1", "bundle:attachment-1", "bundle:attachment-2"
|
||||
]
|
||||
assert output["checkpoint"]["attachmentMarkdowns"] == [""]
|
||||
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
|
||||
assert len(comments) == 1
|
||||
assert comments[0]["body"]["body"].split("\n\n") == [
|
||||
"**Screenshot 1 — First state**", "",
|
||||
"**Screenshot 2 — Tap \\[Submit\\]\\(unsafe\\)**", "",
|
||||
""
|
||||
]
|
||||
|
||||
|
||||
|
||||
def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
@@ -633,22 +730,32 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
await store.reconcile([{{
|
||||
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',blob:new Blob(['first-image-bytes'],{{type:'image/png'}})}},
|
||||
}}]);
|
||||
}},{{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
|
||||
{{filename:'a.png',contentType:'image/png',blob:new Blob(['a-bytes'],{{type:'image/png'}})}},
|
||||
{{filename:'b.png',contentType:'image/png',blob:new Blob(['b-bytes'],{{type:'image/png'}})}},
|
||||
]}}]);
|
||||
await store.reconcile([
|
||||
{{id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',stored:true}}}},
|
||||
{{id:'second',operationId:'second',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'two.png',contentType:'image/png',data:'c2Vjb25kLWltYWdlLWJ5dGVz'}}}},
|
||||
{{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
|
||||
{{filename:'a.png',contentType:'image/png',stored:true}},
|
||||
{{filename:'b.png',contentType:'image/png',stored:true}},
|
||||
]}},
|
||||
]);
|
||||
const snapshot=await store.snapshot();
|
||||
process.stdout.write(JSON.stringify({{first:{{isBlob:snapshot[0].attachment.blob instanceof Blob,
|
||||
text:await snapshot[0].attachment.blob?.text()}},second:snapshot[1].attachment.data}}));
|
||||
const byId=Object.fromEntries(snapshot.map(value=>[value.id,value]));
|
||||
process.stdout.write(JSON.stringify({{first:{{isBlob:byId.first.attachment.blob instanceof Blob,
|
||||
text:await byId.first.attachment.blob?.text()}},second:byId.second.attachment.data,
|
||||
bundle:await Promise.all(byId.bundle.attachments.map(value=>value.blob?.text()))}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == {"isBlob": True, "text": "first-image-bytes"}
|
||||
assert output["second"] == "c2Vjb25kLWltYWdlLWJ5dGVz"
|
||||
assert output["bundle"] == ["a-bytes", "b-bytes"]
|
||||
|
||||
|
||||
def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():
|
||||
|
||||
@@ -155,6 +155,55 @@ first.run([item]).then(firstResult=>{{
|
||||
}
|
||||
|
||||
|
||||
def test_interrupted_batch_restores_operation_context_for_idempotent_recovery():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const values={{}};
|
||||
const storage={{
|
||||
getItem:key=>values[key] || null,
|
||||
setItem:(key,value)=>{{values[key]=value;}},
|
||||
removeItem:key=>{{delete values[key];}},
|
||||
}};
|
||||
const calls=[];
|
||||
const options={{
|
||||
capacity:()=>2, storage, owner:()=> 'timmy', journalName:'search-later-batch',
|
||||
queueFailureReason:'assigned but Later could not be saved',
|
||||
claim:item=>Promise.resolve({{...item,assigned_to_me:true}}),
|
||||
}};
|
||||
const first=createBatchFindWork({{...options, queue:(item,context)=>{{
|
||||
calls.push(['first',item.number,context?.until]);
|
||||
return item.number===813 ? 'queued' : 'unavailable';
|
||||
}}}});
|
||||
const items=[813,814].map(number=>({{repository:'stackchain/dashboard',number}}));
|
||||
first.run(items, {{}}, {{until:'2026-08-15T09:00:00.000Z'}}).then(firstResult=>{{
|
||||
const second=createBatchFindWork({{...options, queue:(item,context)=>{{
|
||||
calls.push(['resume',item.number,context?.until]);
|
||||
return 'queued';
|
||||
}}}});
|
||||
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
|
||||
firstResult,resumed,calls,keys:Object.keys(values),
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["firstResult"]["queued"] == ["stackchain/dashboard#813"]
|
||||
assert result["firstResult"]["failed"] == [{
|
||||
"key": "stackchain/dashboard#814",
|
||||
"reason": "assigned but Later could not be saved",
|
||||
"assigned": True,
|
||||
}]
|
||||
assert result["resumed"]["queued"] == [
|
||||
"stackchain/dashboard#813", "stackchain/dashboard#814",
|
||||
]
|
||||
assert result["calls"] == [
|
||||
["first", 813, "2026-08-15T09:00:00.000Z"],
|
||||
["first", 814, "2026-08-15T09:00:00.000Z"],
|
||||
["resume", 814, "2026-08-15T09:00:00.000Z"],
|
||||
]
|
||||
assert result["keys"] == []
|
||||
|
||||
|
||||
def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
|
||||
@@ -21,8 +21,22 @@ def test_release_promotion_waits_for_tests_and_bundle():
|
||||
text = WORKFLOW.read_text()
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
|
||||
assert "needs: [lint, build-release]" in release
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
assert "github.event_name == 'push'" in release
|
||||
assert "actions/download-artifact@v3" in release
|
||||
assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release
|
||||
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
|
||||
|
||||
|
||||
def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
text = WORKFLOW.read_text()
|
||||
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
|
||||
assert "needs: build-release" in browser
|
||||
assert "actions/download-artifact@v3" in browser
|
||||
assert "pip install -r requirements-e2e.txt" in browser
|
||||
assert "python3 -m playwright install --with-deps chromium" in browser
|
||||
assert 'STACKCHAIN_RUN_RELEASE_E2E: "1"' in browser
|
||||
assert "python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py -q" in browser
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v101" in worker
|
||||
assert "stackchain-dashboard-shell-v102" in worker
|
||||
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
HYDRATOR = FRONTEND / "conversation-action-hydrator.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_conversation_paints_before_optional_actions_load_and_hydrates_once():
|
||||
script = f"""
|
||||
const createConversationActionHydrator = require({json.dumps(str(HYDRATOR))});
|
||||
const events = [];
|
||||
let finishLoad;
|
||||
const hydrator = createConversationActionHydrator({{
|
||||
load: () => {{
|
||||
events.push('load');
|
||||
return new Promise(resolve => {{ finishLoad = resolve; }});
|
||||
}},
|
||||
activate: () => ({{name:'actions'}}),
|
||||
}});
|
||||
const root = {{}};
|
||||
const retry = {{hidden:false}};
|
||||
const status = {{textContent:'2 messages loaded.'}};
|
||||
const state = {{comments:[1,2]}};
|
||||
const pending = hydrator.show({{
|
||||
root, state, retry, status,
|
||||
paint: (_state, actions) => events.push(actions ? 'paint-actions' : 'paint-core'),
|
||||
wire: () => events.push('wire'),
|
||||
}});
|
||||
events.push('returned');
|
||||
finishLoad();
|
||||
pending.then(() => {{
|
||||
hydrator.show({{
|
||||
root, state, retry, status,
|
||||
paint: (_state, actions) => events.push(actions ? 'paint-actions-again' : 'paint-core-again'),
|
||||
wire: () => events.push('wire-again'),
|
||||
}}).then(() => process.stdout.write(JSON.stringify({{events,retry,status:status.textContent}})));
|
||||
}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"events": [
|
||||
"paint-core", "load", "returned", "wire", "paint-actions",
|
||||
"paint-actions-again",
|
||||
],
|
||||
"retry": {"hidden": True},
|
||||
"status": "2 messages loaded.",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_comment_actions_keep_conversation_usable_and_retry_in_place():
|
||||
script = f"""
|
||||
const createConversationActionHydrator = require({json.dumps(str(HYDRATOR))});
|
||||
const events = [];
|
||||
let attempt = 0;
|
||||
const hydrator = createConversationActionHydrator({{
|
||||
load: async () => {{
|
||||
attempt += 1;
|
||||
if (attempt === 1) throw new Error('chunk unavailable');
|
||||
}},
|
||||
activate: () => ({{name:'actions'}}),
|
||||
}});
|
||||
const root = {{}};
|
||||
const retry = {{hidden:true}};
|
||||
const status = {{textContent:'2 messages loaded.'}};
|
||||
const options = {{
|
||||
root, retry, status, state:{{comments:[1,2]}},
|
||||
paint: (_state, actions) => events.push(actions ? 'actions' : 'core'),
|
||||
wire: () => events.push('wire'),
|
||||
}};
|
||||
(async () => {{
|
||||
const failed = await hydrator.show(options);
|
||||
events.push('reply-still-usable');
|
||||
const recovered = await hydrator.show(options);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
failed, recovered, attempt, events, retry, status:status.textContent,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"failed": False,
|
||||
"recovered": True,
|
||||
"attempt": 2,
|
||||
"events": ["core", "reply-still-usable", "core", "wire", "actions"],
|
||||
"retry": {"hidden": True},
|
||||
"status": "2 messages loaded.",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_pull_and_update_conversations_trigger_optional_actions_not_startup():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
javascript = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
assert '<script src="static/conversation-action-hydrator.js"></script>' in html
|
||||
assert html.index("static/conversation-action-hydrator.js") < html.index("static/dashboard.js")
|
||||
assert "await commentActionFeatures.run('comment-actions'" not in javascript
|
||||
assert "load: () => commentActionFeatures.load('comment-actions')" in javascript
|
||||
assert "createConversationActionHydrator" in javascript
|
||||
for kind in ("issue", "pull", "update"):
|
||||
assert f"retry-{kind}-comment-actions" in html
|
||||
assert f"showConversationWithActions('{kind}'" in javascript
|
||||
assert f"qs('#retry-{kind}-comment-actions').addEventListener" in javascript
|
||||
|
||||
|
||||
def test_comment_action_retry_is_a_phone_sized_inline_control():
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert ".conversation-actions-retry" in css
|
||||
rule = css.split(".conversation-actions-retry", 1)[1].split("}", 1)[0]
|
||||
assert "min-height:44px" in rule
|
||||
assert "max-width:100%" in rule
|
||||
@@ -16,7 +16,7 @@ def run_node(script):
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_confirmed_issue_is_planned_synced_warmed_and_started_once():
|
||||
def test_confirmed_issue_persists_and_syncs_estimate_before_starting_once():
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
|
||||
@@ -26,13 +26,22 @@ if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
|
||||
const calls = [];
|
||||
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:387}};
|
||||
const flow = createCreateAndStart({{
|
||||
todayWork: {{limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':', add:()=>{{calls.push('add');return 'added';}}}},
|
||||
todaySync: {{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}}, flush:()=>calls.push('flush')}},
|
||||
todayWork: {{
|
||||
limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':',
|
||||
add:()=>{{calls.push('add');return 'added';}},
|
||||
planning:()=>({{capacity_minutes:120,estimates:{{}}}}),
|
||||
replacePlanning:plan=>{{calls.push('plan:'+JSON.stringify(plan));return true;}},
|
||||
}},
|
||||
todaySync: {{
|
||||
enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}},
|
||||
enqueueConfiguration:(capacity,estimates)=>{{calls.push('configure:'+capacity+':'+JSON.stringify(estimates));return true;}},
|
||||
flush:()=>calls.push('flush')
|
||||
}},
|
||||
refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:item=>calls.push('start:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
const result = flow.complete(issue);
|
||||
flow.complete(issue);
|
||||
const result = flow.complete(issue, 45);
|
||||
flow.complete(issue, 45);
|
||||
process.stdout.write(JSON.stringify({{available:true,result,calls}}));
|
||||
}}
|
||||
"""
|
||||
@@ -42,7 +51,9 @@ if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"add",
|
||||
'plan:{"capacity_minutes":120,"estimates":{"issue:stackchain/dashboard:387:":45}}',
|
||||
"add:issue:stackchain/dashboard:387:",
|
||||
'configure:120:{"issue:stackchain/dashboard:387:":45}',
|
||||
"refresh",
|
||||
"flush",
|
||||
"warm",
|
||||
@@ -61,8 +72,14 @@ def test_mobile_capture_wires_distinct_create_and_start_intent_into_the_offline_
|
||||
assert '<button id="create-and-start-issue" type="submit">Create & start</button>' in html
|
||||
assert '<script src="static/create-and-start.js"></script>' in html
|
||||
assert "const createAndStart = createCreateAndStart({" in dashboard
|
||||
assert "event.submitter?.id === 'create-and-start-issue'" in dashboard
|
||||
assert "createAndStart.complete(created)" in dashboard
|
||||
assert "event.submitter.id === 'create-and-start-issue'" in dashboard
|
||||
assert "review.intent === 'create-and-start'" in dashboard
|
||||
assert 'id="create-issue-estimate"' in html
|
||||
assert 'min="5" max="1440" step="5"' in html
|
||||
assert "createAndStart.capacity(qs('#create-issue-estimate').value)" in dashboard
|
||||
assert "estimateMinutes: startCapacity.minutes" in dashboard
|
||||
assert "createAndStart.complete(created, completion.estimateMinutes)" in dashboard
|
||||
assert ".create-issue-estimate" in css
|
||||
assert ".create-issue-actions" in css and "grid-template-columns" in css
|
||||
assert "BASE + 'static/create-and-start.js'" in worker
|
||||
|
||||
@@ -125,3 +142,61 @@ process.stdout.write(JSON.stringify({{first,second,calls}}));
|
||||
"refresh", "flush", "warm", "start",
|
||||
"Created, added to Today, and ready to work.",
|
||||
]
|
||||
|
||||
|
||||
def test_create_and_start_reports_validation_and_remaining_today_capacity():
|
||||
script = f"""
|
||||
const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});
|
||||
const flow=createCreateAndStart({{
|
||||
todayWork:{{limit:5,read:()=>['first','second'],planning:()=>({{
|
||||
capacity_minutes:120,estimates:{{first:30,second:40}}
|
||||
}})}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
empty:flow.capacity(''),tooSmall:flow.capacity('4'),fits:flow.capacity('45'),over:flow.capacity('55')
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"empty": {"valid": False, "reason": "required"},
|
||||
"tooSmall": {"valid": False, "reason": "range"},
|
||||
"fits": {"valid": True, "minutes": 45, "capacityMinutes": 120, "plannedMinutes": 70,
|
||||
"remainingMinutes": 50, "projectedMinutes": 5, "fits": True},
|
||||
"over": {"valid": True, "minutes": 55, "capacityMinutes": 120, "plannedMinutes": 70,
|
||||
"remainingMinutes": 50, "projectedMinutes": -5, "fits": False},
|
||||
}
|
||||
|
||||
|
||||
def test_create_and_start_does_not_start_when_estimate_cannot_be_saved():
|
||||
script = f"""
|
||||
const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});const calls=[];
|
||||
const flow=createCreateAndStart({{
|
||||
todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:o/r:849:',add:()=> 'added',
|
||||
planning:()=>({{capacity_minutes:60,estimates:{{}}}}),replacePlanning:()=>false}},
|
||||
todaySync:{{enqueue:()=>true,enqueueConfiguration:()=>true,flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'),
|
||||
announce:message=>calls.push(message),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{result:flow.complete({{repository:'o/r',number:849}},30),calls}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": "estimate-unavailable",
|
||||
"calls": ["Created and added to Today, but its estimate could not be saved on this device."],
|
||||
}
|
||||
|
||||
|
||||
def test_create_and_start_does_not_start_when_estimate_sync_cannot_be_queued():
|
||||
script = f"""
|
||||
const createCreateAndStart=require({json.dumps(str(CREATE_AND_START))});const calls=[];
|
||||
const flow=createCreateAndStart({{
|
||||
todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:o/r:849:',add:()=> 'added',
|
||||
planning:()=>({{capacity_minutes:60,estimates:{{}}}}),replacePlanning:()=>true}},
|
||||
todaySync:{{enqueue:()=>true,enqueueConfiguration:()=>false,flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'),
|
||||
announce:message=>calls.push(message),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{result:flow.complete({{repository:'o/r',number:849}},30),calls}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": "sync-unavailable",
|
||||
"calls": ["Created and estimated on this device, but Today plan sync is unavailable."],
|
||||
}
|
||||
@@ -270,13 +270,17 @@ const values = new Map([
|
||||
]);
|
||||
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const inbox = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
|
||||
process.stdout.write(JSON.stringify(inbox.partition()));
|
||||
const partition=inbox.partition();
|
||||
process.stdout.write(JSON.stringify({{...partition,labels:partition.deliveries.map(inbox.deliveryLabel)}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert len(output["drafts"]) == 1
|
||||
assert len(output["deliveries"]) == 5
|
||||
assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2, "authorization": 0}
|
||||
assert output["labels"] == [
|
||||
"Needs attention", "Identity protected", "Needs attention", "Sending", "Queued for sync"
|
||||
]
|
||||
assert [item["outbox_id"] for item in output["retryable"]] == ["waiting"]
|
||||
waiting = next(item for item in output["deliveries"] if item["outbox_id"] == "waiting")
|
||||
assert waiting["last_attempt_at"] == 90
|
||||
@@ -293,7 +297,7 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
assert 'class="draft-resume"' in html
|
||||
assert 'class="draft-discard"' in html
|
||||
assert 'Discard this unfinished draft?' in html
|
||||
assert '.draft-actions button { min-height:44px;' in html
|
||||
assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px;' in html
|
||||
assert 'createDraftInbox({ storage: localStorage' in html
|
||||
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
|
||||
assert "Verified not posted — retry" in html
|
||||
@@ -316,7 +320,8 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
|
||||
assert "const deliveryCenter = draftInbox.partition(displayedDrafts);" in html
|
||||
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
|
||||
assert "deliveryCenter.retryable.length" in html
|
||||
assert "item.status === 'sending' ? 'Sending'" in html
|
||||
assert "draftInbox.deliveryLabel(item)" in html
|
||||
assert "if (item.status === 'sending') return 'Sending'" in DRAFTS.read_text()
|
||||
assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html
|
||||
assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html
|
||||
assert '.delivery-center button { min-height:44px;' in html
|
||||
@@ -330,7 +335,7 @@ async def test_mobile_dashboard_requires_an_explicit_authorize_and_close_gesture
|
||||
assert '>Authorize & close</button>' in html
|
||||
assert "list.querySelectorAll('.draft-authorize')" in html
|
||||
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
|
||||
assert "item.status === 'authorization' ? 'Awaiting authorization'" in html
|
||||
assert "if (item.status === 'authorization') return 'Awaiting authorization'" in DRAFTS.read_text()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -74,8 +74,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.
|
||||
assert len(first.runtime_gzip_bytes) <= 98 * 1024
|
||||
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
|
||||
assert len(first.runtime_gzip_bytes) <= 99 * 1024
|
||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||
|
||||
@@ -66,6 +66,64 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages(
|
||||
assert result["items"][0]["work_reasons"] == ["review_requested"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_filed_work_page_loads_open_authored_issues_with_a_distinct_reason():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(200, headers={"X-Total-Count": "61"}, json=[{
|
||||
"id": 870, "number": 870, "title": "Delegated filing", "state": "open",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "https://forge.example/stackchain/dashboard/issues/870",
|
||||
}])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.work_page("filed", page=2)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert requests == [
|
||||
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&created=true&type=issues&limit=50&page=2"
|
||||
]
|
||||
assert result == {
|
||||
"stream": "filed", "page": 2, "total": 61, "has_more": False,
|
||||
"items": [{
|
||||
"id": 870, "number": 870, "title": "Delegated filing", "state": "open",
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "https://forge.example/stackchain/dashboard/issues/870",
|
||||
"work_reasons": ["created_by_me"],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_context_merges_self_assigned_and_filed_streams_without_duplicates(monkeypatch):
|
||||
async def page(stream, page=1, limit=50):
|
||||
del page, limit
|
||||
common = {
|
||||
"id": 7, "number": 7, "title": "My own filing", "state": "open",
|
||||
"repository": {"full_name": "stackchain/api"}, "html_url": "https://forge.example/issues/7",
|
||||
}
|
||||
if stream == "issue":
|
||||
return {"items": [{**common, "assignees": [{"login": "timmy"}]}], "page": 1, "total": 1, "has_more": False}
|
||||
return {"items": [{**common, "work_reasons": ["created_by_me"]}, {
|
||||
**common, "id": 8, "number": 8, "title": "Delegated",
|
||||
"work_reasons": ["created_by_me"],
|
||||
}], "page": 1, "total": 2, "has_more": False}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", page)
|
||||
result = await gitea_proxy.issues()
|
||||
|
||||
assert [item["number"] for item in result] == [7, 8]
|
||||
assert result[0]["work_reasons"] == ["created_by_me"]
|
||||
assert result.pagination == {
|
||||
"issue": {"page": 1, "total": 1, "has_more": False},
|
||||
"filed": {"page": 1, "total": 2, "has_more": False},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_priority():
|
||||
requests = []
|
||||
@@ -568,7 +626,7 @@ async def test_confirmed_claim_is_removed_from_retained_available_snapshot(monke
|
||||
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
|
||||
async def fake_page(stream, page=1, limit=50):
|
||||
assert page == 1
|
||||
totals = {"issue": 84, "pull": 61, "review": 73}
|
||||
totals = {"issue": 84, "filed": 62, "pull": 61, "review": 73}
|
||||
return {
|
||||
"items": [], "page": 1, "total": totals[stream],
|
||||
"has_more": True, "stream": stream,
|
||||
@@ -580,7 +638,8 @@ async def test_initial_work_collections_expose_independent_pagination(monkeypatc
|
||||
pulls = await gitea_proxy.pull_requests()
|
||||
|
||||
assert assigned_issues.pagination == {
|
||||
"issue": {"page": 1, "total": 84, "has_more": True}
|
||||
"issue": {"page": 1, "total": 84, "has_more": True},
|
||||
"filed": {"page": 1, "total": 62, "has_more": True},
|
||||
}
|
||||
assert pulls.pagination == {
|
||||
"pull": {"page": 1, "total": 61, "has_more": True},
|
||||
@@ -602,6 +661,7 @@ async def test_work_collections_include_supported_review_request_search(monkeypa
|
||||
assert await gitea_proxy.pull_requests() == []
|
||||
assert requested_streams == [
|
||||
("issue", 1, 50),
|
||||
("filed", 1, 50),
|
||||
("pull", 1, 50),
|
||||
("review", 1, 50),
|
||||
]
|
||||
|
||||
@@ -8,6 +8,121 @@ from src import gitea_proxy, main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_issue_templates_are_bounded_and_normalized():
|
||||
async def handler(request):
|
||||
assert request.url.path == "/api/v1/repos/stackchain/api/issue_templates"
|
||||
return httpx.Response(200, json=[
|
||||
{
|
||||
"name": " Bug report ", "about": " Reproduce a defect ",
|
||||
"title": "[Bug] ", "content": "## Steps\n1. ",
|
||||
"labels": ["bug", " needs-triage ", "bug", 42],
|
||||
},
|
||||
{"name": "", "content": "ignored"},
|
||||
"malformed",
|
||||
] + [{"name": f"Template {index}", "content": "x"} for index in range(30)])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
templates = await gitea_proxy.repo_issue_templates("stackchain/api")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert templates[0] == {
|
||||
"id": "bug-report",
|
||||
"name": "Bug report",
|
||||
"about": "Reproduce a defect",
|
||||
"title": "[Bug] ",
|
||||
"body": "## Steps\n1.",
|
||||
"labels": ["bug", "needs-triage"],
|
||||
}
|
||||
assert len(templates) == 20
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_issue_templates_route_checks_access_and_returns_blank_fallback(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def access(repository):
|
||||
calls.append(("access", repository))
|
||||
return {"full_name": repository}
|
||||
|
||||
async def templates(repository):
|
||||
calls.append(("templates", repository))
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_issue_templates", templates, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/repos/stackchain/api/issue-templates")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
assert calls == [("access", "stackchain/api"), ("templates", "stackchain/api")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_filing_metadata_authorizes_once_and_isolates_concurrent_section_failures(monkeypatch):
|
||||
calls = []
|
||||
started = set()
|
||||
all_started = asyncio.Event()
|
||||
|
||||
async def access(repository):
|
||||
calls.append(("access", repository))
|
||||
return {"full_name": repository}
|
||||
|
||||
async def section(name, result=None, error=None):
|
||||
calls.append((name, "stackchain/api"))
|
||||
started.add(name)
|
||||
if len(started) == 3:
|
||||
all_started.set()
|
||||
await asyncio.wait_for(all_started.wait(), timeout=0.2)
|
||||
if error:
|
||||
raise error
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repo_labels",
|
||||
lambda repository: section("labels", [{"id": 3, "name": "P0"}]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repo_milestones",
|
||||
lambda repository: section("milestones", [{"id": 9, "title": "August RC"}]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repo_issue_templates",
|
||||
lambda repository: section("templates", error=RuntimeError("templates unavailable")),
|
||||
)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issue-filing-metadata"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"labels": {"available": True, "items": [{"id": 3, "name": "P0"}]},
|
||||
"milestones": {
|
||||
"available": True,
|
||||
"items": [{"id": 9, "title": "August RC"}],
|
||||
},
|
||||
"templates": {
|
||||
"available": False,
|
||||
"items": [],
|
||||
"error": "Issue types could not be loaded.",
|
||||
},
|
||||
}
|
||||
assert calls.count(("access", "stackchain/api")) == 1
|
||||
assert set(calls[1:]) == {
|
||||
("labels", "stackchain/api"),
|
||||
("milestones", "stackchain/api"),
|
||||
("templates", "stackchain/api"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_owned_comment_mutations_verify_thread_and_author_before_writing():
|
||||
requests = []
|
||||
@@ -215,6 +330,81 @@ async def test_gitea_milestone_update_revalidates_assignment_and_open_repository
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("due_date", ["2026-08-31T23:59:59Z", None])
|
||||
async def test_release_plan_applies_milestone_and_optional_deadline_in_one_patch(due_date):
|
||||
requests = []
|
||||
existing_due_date = "2026-08-20T23:59:59Z"
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "GET" and request.url.path.endswith("/issues/17"):
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "pull_request": None,
|
||||
"assignees": [{"login": "timmy"}], "due_date": existing_due_date,
|
||||
})
|
||||
if request.method == "GET" and request.url.path.endswith("/milestones"):
|
||||
return httpx.Response(200, json=[
|
||||
{"id": 9, "title": "August RC", "state": "open"},
|
||||
])
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open",
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"due_date": due_date or existing_due_date,
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.update_assigned_issue_release_plan(
|
||||
"stackchain/api", 17, 9, due_date
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
patch_requests = [request for request in requests if request.method == "PATCH"]
|
||||
assert len(patch_requests) == 1
|
||||
expected_payload = {"milestone": 9}
|
||||
if due_date:
|
||||
expected_payload["due_date"] = due_date
|
||||
assert json.loads(patch_requests[0].content) == expected_payload
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "state": "open",
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
"due_date": due_date or existing_due_date,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_release_plan_endpoint_is_bounded_and_returns_confirmed_plan(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def update(repository, number, milestone_id, due_date):
|
||||
calls.append((repository, number, milestone_id, due_date))
|
||||
return {
|
||||
"repository": repository, "number": number, "state": "open",
|
||||
"milestone": {"id": milestone_id, "title": "August RC"},
|
||||
"due_date": due_date,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "update_assigned_issue_release_plan", update, 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/api/issues/17/release-plan",
|
||||
json={"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["milestone"] == {"id": 9, "title": "August RC"}
|
||||
assert response.json()["due_date"] == "2026-08-31T23:59:59Z"
|
||||
assert calls == [("stackchain/api", 17, 9, "2026-08-31T23:59:59Z")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch):
|
||||
calls = []
|
||||
@@ -545,6 +735,106 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_endpoint_validates_and_assigns_selected_initial_owner(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def access(repository):
|
||||
return {"full_name": repository}
|
||||
|
||||
async def candidates(repository):
|
||||
assert repository == "stackchain/api"
|
||||
return [{"login": "alex", "name": "Alex"}]
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
calls.append((repository, title, assignee))
|
||||
return {"number": 18, "repository": repository, "assignees": [assignee]}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
|
||||
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": "Delegate at capture", "assignee": "alex"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["assignees"] == ["alex"]
|
||||
assert calls == [("stackchain/api", "Delegate at capture", "alex")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_endpoint_preserves_explicit_no_owner(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def access(repository):
|
||||
return {"full_name": repository}
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
calls.append((repository, title, assignee))
|
||||
return {"number": 19, "repository": repository, "assignees": []}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
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": "Backlog capture", "unassigned": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["assignees"] == []
|
||||
assert calls == [("stackchain/api", "Backlog capture", None)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_endpoint_rejects_named_and_no_owner_together():
|
||||
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": "Contradictory owner", "unassigned": True, "assignee": "alex"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "assignee" in response.text
|
||||
assert "unassigned" in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_initial_owner_candidates_require_repository_access_and_are_bounded(monkeypatch):
|
||||
async def access(repository):
|
||||
return {"full_name": repository} if repository == "stackchain/api" else None
|
||||
|
||||
async def candidates(repository):
|
||||
return ([{"login": "bad user", "name": "Unsafe"}] +
|
||||
[{"login": f"user-{index}", "name": f"User {index}"} for index in range(40)])
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
listed = await client.get("/api/v1/repos/stackchain/api/issue-assignees")
|
||||
missing = await client.get("/api/v1/repos/stackchain/private/issue-assignees")
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.headers["cache-control"] == "no-store"
|
||||
assert len(listed.json()) == 25
|
||||
assert all(item["login"] != "bad user" for item in listed.json())
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
|
||||
calls = []
|
||||
@@ -1043,6 +1333,49 @@ async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmat
|
||||
assert result["labels"] == ["P0"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_issue_omits_assignment_and_confirms_no_owner():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
return httpx.Response(201, json={
|
||||
"id": 83, "number": 19, "title": "Backlog capture", "state": "open",
|
||||
"updated_at": "2026-08-07T03:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/issues/19",
|
||||
"assignees": [], "labels": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.create_issue(
|
||||
"stackchain/api", "Backlog capture", "Context", None, []
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert json.loads(requests[0].content) == {
|
||||
"title": "Backlog capture", "body": "Context"
|
||||
}
|
||||
assert result["assignees"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_issue_requires_exact_selected_initial_owner():
|
||||
async def handler(_request):
|
||||
return httpx.Response(201, json={
|
||||
"id": 82, "number": 18, "title": "Delegate", "state": "open",
|
||||
"assignees": [{"login": "alex"}, {"login": "timmy"}], "labels": [],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
with pytest.raises(ValueError, match="exact issue assignment"):
|
||||
await gitea_proxy.create_issue("stackchain/api", "Delegate", "", "alex", [])
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_issue_requires_confirmed_release_plan():
|
||||
async def handler(_request):
|
||||
@@ -1672,6 +2005,40 @@ async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkey
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_detail_endpoint_returns_authored_filing_in_read_only_mode(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def authored(repository, number):
|
||||
calls.append(("authored", repository, number))
|
||||
return (repository, number) == ("stackchain/api", 8)
|
||||
|
||||
async def detail(repository, number):
|
||||
calls.append(("detail", repository, number))
|
||||
return {"repository": repository, "number": number, "title": "Delegated filing"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_detail", detail)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/8/detail?access=filed"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"repository": "stackchain/api",
|
||||
"number": 8,
|
||||
"title": "Delegated filing",
|
||||
"read_only": True,
|
||||
}
|
||||
assert calls == [
|
||||
("authored", "stackchain/api", 8),
|
||||
("detail", "stackchain/api", 8),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no_store(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -58,6 +58,95 @@ controller.select(file);
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_evidence_bundle_keeps_order_limits_selection_and_uploads_each_image_once():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
let sequence=0; const calls=[];
|
||||
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||
const controller=attachment.create({{
|
||||
maxFiles:5,
|
||||
createOperationId:()=> 'evidence-' + (++sequence),
|
||||
upload:async payload=>{{calls.push([payload.filename,payload.operation_id]);return {{markdown:''}};}},
|
||||
}});
|
||||
for(const name of ['one.png','two.png','three.png','four.png','five.png']) controller.select(image(name));
|
||||
let limit='';try{{controller.select(image('six.png'));}}catch(error){{limit=error.message;}}
|
||||
controller.remove(1);
|
||||
controller.select(image('replacement.png'));
|
||||
(async()=>{{
|
||||
const serialized=await controller.serialize();
|
||||
const comment=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
|
||||
const second=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
|
||||
process.stdout.write(JSON.stringify({{limit,state:controller.state(),names:serialized.map(x=>x.filename),comment,second,calls}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["limit"] == "Up to 5 screenshots. Remove one before adding another."
|
||||
assert output["names"] == ["one.png", "three.png", "four.png", "five.png", "replacement.png"]
|
||||
assert [item["name"] for item in output["state"]] == output["names"]
|
||||
assert output["comment"] == output["second"]
|
||||
assert output["comment"].startswith("Evidence sequence\n\n![one.png]")
|
||||
assert len(output["calls"]) == 5
|
||||
assert len({key for _, key in output["calls"]}) == 5
|
||||
|
||||
|
||||
def test_mobile_evidence_bundle_reorders_selected_image_for_serialization_and_upload():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const uploaded=[];
|
||||
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||
const controller=attachment.create({{
|
||||
maxFiles:5,
|
||||
upload:async payload=>{{uploaded.push(payload.filename);return {{markdown:''}};}},
|
||||
}});
|
||||
['one.png','two.png','three.png'].forEach(name=>controller.select(image(name)));
|
||||
controller.move(2, 0);
|
||||
controller.move(0, 99);
|
||||
controller.move(0, 1);
|
||||
(async()=>{{
|
||||
const serialized=await controller.serialize();
|
||||
const comment=await controller.prepareComment({{repository:'o/r',number:825}},'Reviewed');
|
||||
process.stdout.write(JSON.stringify({{names:serialized.map(x=>x.filename),uploaded,comment}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["names"] == ["one.png", "three.png", "two.png"]
|
||||
assert output["uploaded"] == output["names"]
|
||||
assert output["comment"].endswith(
|
||||
"\n\n\n\n"
|
||||
)
|
||||
|
||||
|
||||
def test_mobile_evidence_notes_follow_images_and_render_as_safe_ordered_captions():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||
const controller=attachment.create({{
|
||||
maxFiles:5,
|
||||
upload:async payload=>({{markdown:''}}),
|
||||
}});
|
||||
['one.png','two.png','three.png'].forEach(name=>controller.select(image(name)));
|
||||
controller.setNote(0, ' Login *token* is visible ');
|
||||
controller.setNote(1, 'Keyboard hides [Submit](bad)');
|
||||
controller.setNote(2, '');
|
||||
controller.move(1, 0);
|
||||
controller.remove(1);
|
||||
(async()=>{{
|
||||
const serialized=await controller.serialize();
|
||||
const comment=await controller.prepareComment({{repository:'o/r',number:827}},'Evidence');
|
||||
process.stdout.write(JSON.stringify({{serialized:serialized.map(x=>({{filename:x.filename,note:x.note||''}})),comment}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["serialized"] == [
|
||||
{"filename": "two.png", "note": "Keyboard hides [Submit](bad)"},
|
||||
{"filename": "three.png", "note": ""},
|
||||
]
|
||||
assert output["comment"] == (
|
||||
"Evidence\n\n**Screenshot 1 — Keyboard hides \\[Submit\\]\\(bad\\)**\n\n"
|
||||
"\n\n"
|
||||
)
|
||||
|
||||
|
||||
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
@@ -191,6 +280,74 @@ const canvas = {{
|
||||
assert output["name"] == "camera.jpg"
|
||||
|
||||
|
||||
def test_browser_optimizer_downscales_small_file_when_decoded_pixels_exceed_budget():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const original = new Blob(['compressed'], {{type:'image/jpeg'}});
|
||||
original.name = 'panorama.jpg';
|
||||
let closed = false;
|
||||
const attempts = [];
|
||||
const canvas = {{
|
||||
width: 0, height: 0,
|
||||
getContext: () => ({{drawImage: () => {{}}}}),
|
||||
toBlob: callback => {{
|
||||
attempts.push([canvas.width, canvas.height]);
|
||||
callback(new Blob(['bounded'], {{type:'image/jpeg'}}));
|
||||
}},
|
||||
}};
|
||||
(async()=>{{
|
||||
const result = await attachment.optimizeImage(original, {{
|
||||
createImageBitmap: async () => ({{width:6000,height:4000,close:()=>{{closed=true;}}}}),
|
||||
createCanvas: () => canvas,
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
attempts,closed,size:result.size,type:result.type,name:result.name,
|
||||
maxPixels:attachment.MAX_PIXELS,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert len(output["attempts"]) == 1
|
||||
width, height = output["attempts"][0]
|
||||
assert width * height <= output["maxPixels"]
|
||||
assert width < 6000
|
||||
assert height < 4000
|
||||
assert output["closed"] is True
|
||||
assert output["size"] == 7
|
||||
assert output["type"] == "image/jpeg"
|
||||
assert output["name"] == "panorama.jpg"
|
||||
|
||||
|
||||
def test_mobile_preview_inspects_small_screenshot_before_rendering_it():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
class Element{{constructor(){{this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;}}addEventListener(t,f){{this.listeners[t]=f;}}dispatch(t){{return this.listeners[t]({{target:this}});}}}}
|
||||
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
|
||||
const original=new Blob(['compressed'],{{type:'image/jpeg'}}); original.name='panorama.jpg';
|
||||
const bounded=new Blob(['bounded'],{{type:'image/jpeg'}}); bounded.name='panorama.jpg';
|
||||
const inspected=[];
|
||||
attachment.mount({{
|
||||
input,preview,image,meta,remove,status,
|
||||
optimizeImage:async file=>{{inspected.push(file.name);return bounded;}},
|
||||
createObjectURL:blob=>blob===bounded?'blob:bounded':'blob:unsafe',revokeObjectURL:()=>{{}},upload:async()=>{{}},
|
||||
}});
|
||||
input.files=[original];
|
||||
(async()=>{{
|
||||
await input.dispatch('change');
|
||||
process.stdout.write(JSON.stringify({{inspected,src:image.src,hidden:preview.hidden,status:status.textContent}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"inspected": ["panorama.jpg"],
|
||||
"src": "blob:bounded",
|
||||
"hidden": False,
|
||||
"status": "Screenshot optimized and ready to upload.",
|
||||
}
|
||||
|
||||
|
||||
def test_binary_screenshot_builds_multipart_body_with_original_bytes():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
@@ -224,6 +381,122 @@ controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0K
|
||||
assert output["removed"] is None
|
||||
|
||||
|
||||
def test_attachment_view_restores_ordered_evidence_bundle_preview():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
class Element{{constructor(){{this.listeners={{}};this.hidden=true;this.value='';this.textContent='';this.src='';this.disabled=false;}}addEventListener(t,f){{this.listeners[t]=f;}}}}
|
||||
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();input.multiple=true;
|
||||
const controller=attachment.mount({{input,preview,image,meta,remove,status,createObjectURL:blob=>'blob:'+blob.size,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
|
||||
const value=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
|
||||
const restored=controller.restore([value('one.png'),value('two.png')]);
|
||||
process.stdout.write(JSON.stringify({{restored,src:image.src,meta:meta.textContent,hidden:preview.hidden}}));
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert [item["name"] for item in output["restored"]] == ["one.png", "two.png"]
|
||||
assert output["src"] == "blob:7"
|
||||
assert output["meta"] == "2 screenshots ready · latest: two.png"
|
||||
assert output["hidden"] is False
|
||||
|
||||
|
||||
|
||||
def test_evidence_review_tray_selects_reorders_and_removes_any_screenshot():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
class Element{{
|
||||
constructor(tag='div'){{this.tag=tag;this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}};this.dataset={{}};}}
|
||||
addEventListener(type,fn){{this.listeners[type]=fn;}}
|
||||
dispatch(type){{return this.listeners[type]({{target:this}});}}
|
||||
appendChild(child){{this.children.push(child);return child;}}
|
||||
replaceChildren(...children){{this.children=children;}}
|
||||
setAttribute(name,value){{this.attributes[name]=String(value);}}
|
||||
}}
|
||||
const document={{createElement:tag=>new Element(tag)}};
|
||||
const input=new Element('input'),preview=new Element(),image=new Element('img'),meta=new Element(),remove=new Element('button'),status=new Element(),tray=new Element(),earlier=new Element('button'),later=new Element('button');
|
||||
input.multiple=true;
|
||||
const urls=[];
|
||||
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document,
|
||||
removedMessage:'Screenshot removed. Your issue draft is unchanged.',
|
||||
optimizeImage:async file=>file,
|
||||
createObjectURL:blob=>{{const url='blob:'+blob.name;urls.push(url);return url;}},revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
|
||||
const file=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||
input.files=['one.png','two.png','three.png'].map(file);
|
||||
(async()=>{{
|
||||
await input.dispatch('change');
|
||||
await tray.children[1].dispatch('click');
|
||||
await earlier.dispatch('click');
|
||||
const reordered=(await controller.serialize()).map(item=>item.filename);
|
||||
await remove.dispatch('click');
|
||||
const remaining=(await controller.serialize()).map(item=>item.filename);
|
||||
process.stdout.write(JSON.stringify({{reordered,remaining,preview:image.src,meta:meta.textContent,
|
||||
selected:tray.children.map(button=>button.attributes['aria-pressed']),
|
||||
labels:tray.children.map(button=>button.attributes['aria-label']),
|
||||
earlierDisabled:earlier.disabled,laterDisabled:later.disabled,status:status.textContent}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["reordered"] == ["two.png", "one.png", "three.png"]
|
||||
assert output["remaining"] == ["one.png", "three.png"]
|
||||
assert output["preview"] == "blob:one.png"
|
||||
assert output["meta"] == "Screenshot 1 of 2 · one.png · 1 KB"
|
||||
assert output["selected"] == ["true", "false"]
|
||||
assert output["labels"] == [
|
||||
"Review screenshot 1 of 2: one.png", "Review screenshot 2 of 2: three.png"
|
||||
]
|
||||
assert output["earlierDisabled"] is True
|
||||
assert output["laterDisabled"] is False
|
||||
assert output["status"] == "Screenshot removed. Your issue draft is unchanged."
|
||||
|
||||
|
||||
def test_evidence_review_edits_the_active_note_and_keeps_it_visible_after_reorder():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
class Element{{
|
||||
constructor(tag='div'){{this.tag=tag;this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}};}}
|
||||
addEventListener(type,fn){{this.listeners[type]=fn;}}
|
||||
dispatch(type){{return this.listeners[type]({{target:this}});}}
|
||||
appendChild(child){{this.children.push(child);return child;}}
|
||||
replaceChildren(...children){{this.children=children;}}
|
||||
setAttribute(name,value){{this.attributes[name]=String(value);}}
|
||||
}}
|
||||
const document={{createElement:tag=>new Element(tag)}};
|
||||
const input=new Element('input'),preview=new Element(),image=new Element('img'),meta=new Element(),remove=new Element('button'),status=new Element(),tray=new Element(),earlier=new Element('button'),later=new Element('button'),note=new Element('textarea'),noteLabel=new Element('label');
|
||||
input.multiple=true;
|
||||
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,note,noteLabel,document,
|
||||
optimizeImage:async file=>file,
|
||||
createObjectURL:blob=>'blob:'+blob.name,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
|
||||
const file=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||
input.files=['one.png','two.png','three.png'].map(file);
|
||||
(async()=>{{
|
||||
await input.dispatch('change');
|
||||
await tray.children[1].dispatch('click');
|
||||
note.value='Keyboard hides Submit';
|
||||
await note.dispatch('input');
|
||||
await earlier.dispatch('click');
|
||||
const afterMove={{value:note.value,label:noteLabel.textContent,disabled:note.disabled}};
|
||||
await tray.children[1].dispatch('click');
|
||||
const other={{value:note.value,label:noteLabel.textContent}};
|
||||
process.stdout.write(JSON.stringify({{afterMove,other,serialized:(await controller.serialize()).map(x=>[x.filename,x.note||''])}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output == {
|
||||
"afterMove": {
|
||||
"value": "Keyboard hides Submit",
|
||||
"label": "Evidence note for screenshot 1 of 3 (optional)",
|
||||
"disabled": False,
|
||||
},
|
||||
"other": {
|
||||
"value": "",
|
||||
"label": "Evidence note for screenshot 2 of 3 (optional)",
|
||||
},
|
||||
"serialized": [
|
||||
["two.png", "Keyboard hides Submit"],
|
||||
["one.png", ""],
|
||||
["three.png", ""],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
@@ -264,13 +537,36 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
|
||||
|
||||
assert 'id="create-issue-attachment"' in html
|
||||
assert 'accept="image/png,image/jpeg,image/webp"' in html
|
||||
assert 'multiple' in html
|
||||
assert 'Up to 5' in html
|
||||
assert 'id="create-issue-attachment-preview"' in html
|
||||
assert 'id="remove-create-issue-attachment"' in html
|
||||
assert 'id="create-issue-evidence-tray"' in html
|
||||
assert 'role="toolbar"' in html
|
||||
assert 'aria-label="Evidence screenshots"' in html
|
||||
assert 'id="move-create-issue-attachment-earlier"' in html
|
||||
assert 'id="move-create-issue-attachment-later"' in html
|
||||
assert 'id="create-issue-evidence-note-label"' in html
|
||||
assert 'id="create-issue-evidence-note"' in html
|
||||
assert 'maxlength="240"' in html
|
||||
assert "tray: qs('#create-issue-evidence-tray')" in source
|
||||
assert "earlier: qs('#move-create-issue-attachment-earlier')" in source
|
||||
assert "later: qs('#move-create-issue-attachment-later')" in source
|
||||
assert "note: qs('#create-issue-evidence-note')" in source
|
||||
assert "noteLabel: qs('#create-issue-evidence-note-label')" in source
|
||||
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
|
||||
assert "attachment: await createIssueAttachmentController.serialize()" in source
|
||||
assert "createIssueAttachmentController.restore(hydrated.attachment);" in source
|
||||
assert "attachments:evidence" in source
|
||||
assert "hydrated.attachments || hydrated.attachment" in source
|
||||
assert "createIssueAttachmentController.clear();" in source
|
||||
assert ".create-issue-attachment" in css
|
||||
assert ".issue-evidence-tray" in css
|
||||
assert ".issue-evidence-review-actions" in css
|
||||
assert ".issue-evidence-note" in css
|
||||
assert "resize:vertical" in css
|
||||
assert "#create-issue-attachment-preview { grid-template-columns:1fr;" in css
|
||||
assert "#create-issue-attachment-image { grid-row:auto; width:100%;" in css
|
||||
assert "overflow-x:auto" in css
|
||||
assert "min-width:44px" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
|
||||
@@ -303,15 +599,18 @@ const input=new Element(), preview=new Element(), image=new Element(), meta=new
|
||||
const revoked=[];
|
||||
const controller=attachment.mount({{
|
||||
input,preview,image,meta,remove,status,
|
||||
optimizeImage:async file=>file,
|
||||
createObjectURL:()=> 'blob:preview', revokeObjectURL:url=>revoked.push(url),
|
||||
readDataUrl:async()=>'', upload:async()=>{{}},
|
||||
}});
|
||||
input.files=[{{name:'payload.svg',type:'image/svg+xml',size:20}}]; input.dispatch('change');
|
||||
const invalid={{message:status.textContent,hidden:preview.hidden}};
|
||||
input.files=[{{name:'screen.png',type:'image/png',size:2048}}]; input.dispatch('change');
|
||||
const selected={{src:image.src,meta:meta.textContent,hidden:preview.hidden,state:controller.state()}};
|
||||
remove.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({{invalid,selected,removed:{{hidden:preview.hidden,src:image.src,revoked,state:controller.state()}}}}));
|
||||
;(async()=>{{
|
||||
input.files=[{{name:'payload.svg',type:'image/svg+xml',size:20}}]; await input.dispatch('change');
|
||||
const invalid={{message:status.textContent,hidden:preview.hidden}};
|
||||
input.files=[{{name:'screen.png',type:'image/png',size:2048}}]; await input.dispatch('change');
|
||||
const selected={{src:image.src,meta:meta.textContent,hidden:preview.hidden,state:controller.state()}};
|
||||
await remove.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({{invalid,selected,removed:{{hidden:preview.hidden,src:image.src,revoked,state:controller.state()}}}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["invalid"] == {
|
||||
@@ -410,21 +709,38 @@ def test_pending_comment_admission_locks_attachment_replacement_and_remove_contr
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
class Element {{
|
||||
constructor() {{ this.listeners={{}};this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false; }}
|
||||
constructor() {{ this.listeners={{}};this.children=[];this.hidden=true;this.value='';this.files=[];this.textContent='';this.src='';this.disabled=false;this.attributes={{}}; }}
|
||||
addEventListener(type,fn) {{ this.listeners[type]=fn; }}
|
||||
appendChild(child) {{ this.children.push(child); }}
|
||||
replaceChildren(...children) {{ this.children=children; }}
|
||||
setAttribute(name,value) {{ this.attributes[name]=String(value); }}
|
||||
}}
|
||||
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();
|
||||
const controller=attachment.mount({{input,preview,image,meta,remove,status,
|
||||
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element(),tray=new Element(),earlier=new Element(),later=new Element();
|
||||
input.multiple=true;
|
||||
const document={{createElement:()=>new Element()}};
|
||||
const controller=attachment.mount({{input,preview,image,meta,remove,status,tray,earlier,later,document,
|
||||
createObjectURL:()=> 'blob:preview',revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
|
||||
controller.restore([
|
||||
{{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}},
|
||||
{{filename:'two.png',contentType:'image/png',blob:new Blob(['two'],{{type:'image/png'}})}},
|
||||
]);
|
||||
controller.setBusy(true);
|
||||
const pending={{input:input.disabled,remove:remove.disabled}};
|
||||
const pending={{input:input.disabled,remove:remove.disabled,earlier:earlier.disabled,later:later.disabled,
|
||||
thumbnails:tray.children.map(button=>button.disabled)}};
|
||||
controller.setBusy(false);
|
||||
process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled}}}}));
|
||||
process.stdout.write(JSON.stringify({{pending,released:{{input:input.disabled,remove:remove.disabled,
|
||||
earlier:earlier.disabled,later:later.disabled,thumbnails:tray.children.map(button=>button.disabled)}}}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"pending": {"input": True, "remove": True},
|
||||
"released": {"input": False, "remove": False},
|
||||
"pending": {
|
||||
"input": True, "remove": True, "earlier": True, "later": True,
|
||||
"thumbnails": [True, True],
|
||||
},
|
||||
"released": {
|
||||
"input": False, "remove": False, "earlier": False, "later": True,
|
||||
"thumbnails": [False, False],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -462,6 +778,11 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
|
||||
assert "uploads before the comment is posted" in readme
|
||||
assert "New issue" in readme
|
||||
assert "retry resumes with the confirmed issue" in readme
|
||||
assert "thumbnail tray" in readme
|
||||
assert "Move earlier" in readme
|
||||
assert "Remove selected" in readme
|
||||
assert "Evidence note" in readme
|
||||
assert "caption" in readme
|
||||
|
||||
|
||||
def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_advancing():
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
EDITOR = ROOT / "frontend" / "issue-evidence-editor.js"
|
||||
ATTACHMENT = ROOT / "frontend" / "issue-attachment.js"
|
||||
INDEX = ROOT / "frontend" / "index.html"
|
||||
CSS = ROOT / "frontend" / "dashboard.css"
|
||||
SERVICE_WORKER = ROOT / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_editor_crops_redacts_undoes_and_flattens_derivative():
|
||||
script = f"""
|
||||
const editor = require({json.dumps(str(EDITOR))});
|
||||
const model = editor.createModel({{width:400,height:300}});
|
||||
model.setCrop({{x:40,y:30,width:240,height:180}});
|
||||
model.addRedaction({{x:70,y:60,width:80,height:40}});
|
||||
model.addRedaction({{x:180,y:120,width:50,height:30}});
|
||||
model.undo();
|
||||
const calls=[];
|
||||
const canvas={{width:0,height:0,getContext:()=>({{
|
||||
drawImage:(...args)=>calls.push(['draw',...args.slice(1)]),
|
||||
fillRect:(...args)=>calls.push(['fill',...args]),
|
||||
set fillStyle(value){{calls.push(['color',value]);}}
|
||||
}}),toBlob:(callback,type)=>callback(new Blob(['flattened'],{{type}}))}};
|
||||
(async()=>{{
|
||||
const result=await editor.flatten({{source:{{}},model,canvas,name:'private.png',type:'image/png'}});
|
||||
process.stdout.write(JSON.stringify({{snapshot:model.snapshot(),calls,width:canvas.width,height:canvas.height,
|
||||
name:result.name,type:result.type,text:await result.text()}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["snapshot"] == {
|
||||
"crop": {"x": 40, "y": 30, "width": 240, "height": 180},
|
||||
"redactions": [{"x": 70, "y": 60, "width": 80, "height": 40}],
|
||||
"annotations": [],
|
||||
}
|
||||
assert output["width"] == 240
|
||||
assert output["height"] == 180
|
||||
assert output["calls"] == [
|
||||
["draw", 40, 30, 240, 180, 0, 0, 240, 180],
|
||||
["color", "#000000"],
|
||||
["fill", 30, 30, 80, 40],
|
||||
]
|
||||
assert output["name"] == "private.png"
|
||||
assert output["type"] == "image/png"
|
||||
assert output["text"] == "flattened"
|
||||
|
||||
|
||||
def test_applying_edit_replaces_only_selected_blob_and_preserves_note_and_order():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
let id=0;
|
||||
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
|
||||
const controller=attachment.create({{maxFiles:5,createOperationId:()=>`edit-${{++id}}`,upload:async()=>({{markdown:'ok'}})}});
|
||||
['one.png','private.png','three.png'].forEach(name=>controller.select(image(name)));
|
||||
controller.setNote(1,'Token hidden here');
|
||||
controller.replace(1,image('private-edited.png'));
|
||||
(async()=>{{
|
||||
const values=await controller.serialize();
|
||||
process.stdout.write(JSON.stringify({{values:await Promise.all(values.map(async value=>({{name:value.filename,note:value.note||'',text:await value.blob.text()}}))),state:controller.state()}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["values"] == [
|
||||
{"name": "one.png", "note": "", "text": "one.png"},
|
||||
{"name": "private-edited.png", "note": "Token hidden here", "text": "private-edited.png"},
|
||||
{"name": "three.png", "note": "", "text": "three.png"},
|
||||
]
|
||||
assert [item["uploaded"] for item in output["state"]] == [False, False, False]
|
||||
|
||||
|
||||
def test_editor_adds_undoes_and_flattens_highlight_and_arrow_annotations():
|
||||
script = f"""
|
||||
const editor = require({json.dumps(str(EDITOR))});
|
||||
const model = editor.createModel({{width:400,height:300}});
|
||||
model.setCrop({{x:40,y:30,width:240,height:180}});
|
||||
model.addHighlight({{x:60,y:50,width:100,height:60}});
|
||||
model.addArrow({{startX:80,startY:150,endX:220,endY:80}});
|
||||
const beforeUndo=model.snapshot();
|
||||
model.undo();
|
||||
const afterUndo=model.snapshot();
|
||||
model.addArrow({{startX:80,startY:150,endX:220,endY:80}});
|
||||
const calls=[];
|
||||
const context={{
|
||||
drawImage:()=>calls.push(['draw']), fillRect:(...args)=>calls.push(['fill',...args]),
|
||||
save:()=>calls.push(['save']), restore:()=>calls.push(['restore']),
|
||||
beginPath:()=>calls.push(['begin']), moveTo:(...args)=>calls.push(['move',...args]),
|
||||
lineTo:(...args)=>calls.push(['line',...args]), stroke:()=>calls.push(['stroke']),
|
||||
set fillStyle(value){{calls.push(['fillStyle',value]);}},
|
||||
set strokeStyle(value){{calls.push(['strokeStyle',value]);}},
|
||||
set lineWidth(value){{calls.push(['lineWidth',value]);}},
|
||||
set lineCap(value){{calls.push(['lineCap',value]);}},
|
||||
set lineJoin(value){{calls.push(['lineJoin',value]);}},
|
||||
}};
|
||||
const canvas={{width:0,height:0,getContext:()=>context}};
|
||||
editor.paint({{source:{{}},model,canvas}});
|
||||
process.stdout.write(JSON.stringify({{beforeUndo,afterUndo,calls}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert [item["type"] for item in output["beforeUndo"]["annotations"]] == ["highlight", "arrow"]
|
||||
assert [item["type"] for item in output["afterUndo"]["annotations"]] == ["highlight"]
|
||||
assert ["fillStyle", "rgba(250, 204, 21, 0.38)"] in output["calls"]
|
||||
assert ["fill", 20, 20, 100, 60] in output["calls"]
|
||||
assert ["strokeStyle", "#facc15"] in output["calls"]
|
||||
assert ["move", 40, 120] in output["calls"]
|
||||
assert ["line", 180, 50] in output["calls"]
|
||||
assert output["calls"].count(["stroke"]) == 1
|
||||
|
||||
|
||||
def test_crop_clips_annotations_to_the_retained_image_area():
|
||||
script = f"""
|
||||
const editor = require({json.dumps(str(EDITOR))});
|
||||
const model = editor.createModel({{width:300,height:200}});
|
||||
model.addHighlight({{x:20,y:30,width:100,height:80}});
|
||||
model.addArrow({{startX:10,startY:90,endX:120,endY:90}});
|
||||
model.setCrop({{x:50,y:20,width:100,height:120}});
|
||||
process.stdout.write(JSON.stringify(model.snapshot()));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["annotations"] == [
|
||||
{"type": "highlight", "x": 50, "y": 30, "width": 70, "height": 80},
|
||||
{"type": "arrow", "startX": 50, "startY": 90, "endX": 120, "endY": 90},
|
||||
]
|
||||
|
||||
|
||||
def test_mobile_editor_dialog_is_accessible_touch_sized_and_available_offline():
|
||||
html = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert 'id="edit-create-issue-attachment"' in html
|
||||
assert 'id="issue-evidence-editor"' in html
|
||||
assert 'role="dialog"' in html
|
||||
assert 'aria-labelledby="issue-evidence-editor-heading"' in html
|
||||
assert 'id="issue-evidence-editor-canvas"' in html
|
||||
assert 'id="highlight-issue-evidence"' in html
|
||||
assert 'id="arrow-issue-evidence"' in html
|
||||
assert 'aria-label="Screenshot editing canvas. Drag to crop, redact, highlight, or point with an arrow."' in html
|
||||
assert 'aria-pressed="true"' in html
|
||||
assert "issue-evidence-editor.js" in worker
|
||||
assert ".issue-evidence-editor" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "max-width:100%" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
@@ -0,0 +1,136 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RECEIPT = Path(__file__).parents[1] / "frontend" / "issue-filing-receipt.js"
|
||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_confirmed_delegated_issue_opens_an_accessible_shareable_receipt():
|
||||
script = f"""
|
||||
const createReceipt = require({json.dumps(str(RECEIPT))});
|
||||
const element = () => ({{hidden:true,textContent:'',href:'',focused:false,focus(){{this.focused=true;}},addEventListener(name, fn){{this[name]=fn;}}}});
|
||||
const elements = {{root:element(),heading:element(),key:element(),title:element(),ownership:element(),openLink:element(),shareButton:element(),fileAnotherButton:element(),doneButton:element(),status:element()}};
|
||||
const controller = createReceipt({{...elements,navigator:{{share:async payload=>{{elements.shared=payload;}}}}}});
|
||||
controller.show({{repository:'stackchain/dashboard',number:866,title:'Finish the filing journey',assignees:['alex'],url:'https://forge.example/git/stackchain/dashboard/issues/866'}}, elements.doneButton);
|
||||
(async()=>{{await elements.shareButton.click();process.stdout.write(JSON.stringify({{
|
||||
hidden:elements.root.hidden,key:elements.key.textContent,title:elements.title.textContent,
|
||||
ownership:elements.ownership.textContent,href:elements.openLink.href,focused:elements.heading.focused,
|
||||
shared:elements.shared,
|
||||
}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"hidden": False,
|
||||
"key": "stackchain/dashboard#866",
|
||||
"title": "Finish the filing journey",
|
||||
"ownership": "Assigned to @alex.",
|
||||
"href": "https://forge.example/git/stackchain/dashboard/issues/866",
|
||||
"focused": True,
|
||||
"shared": {
|
||||
"title": "stackchain/dashboard#866 · Finish the filing journey",
|
||||
"text": "Finish the filing journey",
|
||||
"url": "https://forge.example/git/stackchain/dashboard/issues/866",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_unassigned_receipt_copies_link_and_escape_restores_launch_focus():
|
||||
script = f"""
|
||||
const createReceipt = require({json.dumps(str(RECEIPT))});
|
||||
const element = () => ({{hidden:true,textContent:'',href:'',focusCount:0,focus(){{this.focusCount++;}},addEventListener(name, fn){{this[name]=fn;}}}});
|
||||
const elements = {{root:element(),heading:element(),key:element(),title:element(),ownership:element(),openLink:element(),shareButton:element(),fileAnotherButton:element(),doneButton:element(),status:element()}};
|
||||
const copied=[];const launcher=element();
|
||||
const controller=createReceipt({{...elements,navigator:{{}},clipboard:{{writeText:async value=>copied.push(value)}}}});
|
||||
controller.show({{repository:'stackchain/dashboard',number:867,title:'Unowned follow-up',assignees:[],url:'https://forge.example/git/stackchain/dashboard/issues/867'}},launcher);
|
||||
(async()=>{{await elements.shareButton.click();elements.root.keydown({{key:'Escape',preventDefault(){{elements.prevented=true;}}}});process.stdout.write(JSON.stringify({{
|
||||
ownership:elements.ownership.textContent,copied,status:elements.status.textContent,hidden:elements.root.hidden,
|
||||
restored:launcher.focusCount,prevented:elements.prevented,
|
||||
}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"ownership": "Created with no owner.",
|
||||
"copied": ["https://forge.example/git/stackchain/dashboard/issues/867"],
|
||||
"status": "Issue link copied.",
|
||||
"hidden": True,
|
||||
"restored": 1,
|
||||
"prevented": True,
|
||||
}
|
||||
|
||||
|
||||
def test_related_action_returns_only_the_confirmed_reusable_plan():
|
||||
script = f"""
|
||||
const createReceipt = require({json.dumps(str(RECEIPT))});
|
||||
const element = () => ({{hidden:true,focusCount:0,focus(){{this.focusCount++;}},addEventListener(name, fn){{this[name]=fn;}}}});
|
||||
const elements = {{root:element(),heading:element(),key:element(),title:element(),ownership:element(),openLink:element(),shareButton:element(),relatedButton:element(),fileAnotherButton:element(),doneButton:element(),status:element()}};
|
||||
const plans=[];
|
||||
const controller=createReceipt({{...elements,onFileRelated:plan=>plans.push(plan)}});
|
||||
const plan={{repository:'stackchain/dashboard',title:'',body:'## Bug',labelIds:[7],templateId:'bug.yml'}};
|
||||
controller.show({{repository:'stackchain/dashboard',number:868,title:'Filed',assignees:[],url:'https://forge.example/issues/868'}},elements.doneButton,plan);
|
||||
elements.relatedButton.click();
|
||||
process.stdout.write(JSON.stringify({{plans,hidden:elements.root.hidden,restored:elements.doneButton.focusCount}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"plans": [{
|
||||
"repository": "stackchain/dashboard", "title": "", "body": "## Bug",
|
||||
"labelIds": [7], "templateId": "bug.yml",
|
||||
}], "hidden": True, "restored": 1}
|
||||
|
||||
|
||||
def test_view_in_filed_closes_receipt_and_routes_the_exact_confirmed_issue():
|
||||
script = f"""
|
||||
const createReceipt = require({json.dumps(str(RECEIPT))});
|
||||
const element = () => ({{hidden:true,focusCount:0,focus(){{this.focusCount++;}},addEventListener(name, fn){{this[name]=fn;}}}});
|
||||
const elements = {{root:element(),heading:element(),key:element(),title:element(),ownership:element(),openLink:element(),shareButton:element(),filedButton:element(),fileAnotherButton:element(),doneButton:element(),status:element()}};
|
||||
const filed=[];
|
||||
const controller=createReceipt({{...elements,onViewFiled:issue=>filed.push(issue)}});
|
||||
controller.show({{repository:'stackchain/dashboard',number:870,title:'Delegated',assignees:['alex'],url:'https://forge.example/issues/870'}},elements.doneButton);
|
||||
elements.filedButton.click();
|
||||
process.stdout.write(JSON.stringify({{filed,hidden:elements.root.hidden,restored:elements.doneButton.focusCount}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output == {
|
||||
"filed": [{"repository": "stackchain/dashboard", "number": 870, "title": "Delegated", "assignees": ["alex"], "url": "https://forge.example/issues/870"}],
|
||||
"hidden": True, "restored": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
assert 'id="issue-filing-receipt" role="dialog" aria-modal="true"' in index
|
||||
assert 'id="issue-filing-receipt-heading"' in index
|
||||
assert 'id="issue-filing-receipt-open"' in index
|
||||
assert 'id="issue-filing-receipt-filed"' in index
|
||||
assert 'id="issue-filing-receipt-share"' in index
|
||||
assert 'id="issue-filing-receipt-related"' in index
|
||||
assert 'id="issue-filing-receipt-another"' in index
|
||||
assert 'id="issue-filing-receipt-done"' in index
|
||||
assert '<script src="static/issue-filing-receipt.js"></script>' in index
|
||||
assert '.issue-filing-receipt-panel' in css
|
||||
assert 'padding-bottom:calc(18px + env(safe-area-inset-bottom))' in css
|
||||
assert '.issue-filing-receipt-actions' in css
|
||||
assert 'min-height:44px' in css
|
||||
assert 'createIssueFilingReceipt({' in dashboard
|
||||
assert "filedButton:qs('#issue-filing-receipt-filed')" in dashboard
|
||||
assert 'onViewFiled:issue =>' in dashboard
|
||||
assert "is_filed:true, is_assigned:(issue.assignees || []).includes(confirmedOwnerLogin)" in dashboard
|
||||
assert "confirmed.work_reasons = ['created_by_me']" in dashboard
|
||||
assert "relatedButton:qs('#issue-filing-receipt-related')" in dashboard
|
||||
assert 'relatedDraft:issueCapture.buildRelatedDraft(durableDraft)' in dashboard
|
||||
assert "issueCapture.saveDraft(plan)" in dashboard
|
||||
assert "issueFilingReceipt.show(confirmed, qs('#new-issue'), filing?.relatedDraft)" in dashboard
|
||||
@@ -0,0 +1,350 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "issue-filing-review.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
completed = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True, check=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_review_requires_one_confirmation_and_preserves_the_reviewed_payload():
|
||||
script = f"""
|
||||
const createReview = require({json.dumps(str(MODULE))});
|
||||
function target() {{
|
||||
const listeners = {{}};
|
||||
return {{
|
||||
disabled:false, hidden:true, textContent:'', children:[],
|
||||
addEventListener:(name, fn)=>listeners[name]=fn,
|
||||
dispatch:(name, event={{}})=>listeners[name]?.({{preventDefault(){{}}, ...event}}),
|
||||
focus(){{this.focused=true;}}, replaceChildren(...items){{this.children=items;}},
|
||||
setAttribute(){{}}, appendChild(item){{this.children.push(item);}},
|
||||
}};
|
||||
}}
|
||||
const sheet=target(), confirm=target(), back=target(), trigger=target(), evidence=target();
|
||||
const documentRef={{
|
||||
createElement:tag=>({{tag, textContent:'', children:[], appendChild(item){{this.children.push(item);}}}}),
|
||||
addEventListener:()=>{{}},
|
||||
}};
|
||||
const admitted=[];
|
||||
const review=createReview({{
|
||||
sheet, confirmButton:confirm, backButton:back, evidenceList:evidence,
|
||||
repository:target(), intent:target(), title:target(), body:target(), metadata:target(),
|
||||
document:documentRef,
|
||||
onConfirm:async payload=>{{ admitted.push(payload); return {{ok:true}}; }},
|
||||
}});
|
||||
const payload={{
|
||||
draft:{{repository:'stackchain/dashboard',title:'Ship it',body:'Full note',labels:['P1'],
|
||||
milestone:{{title:'Sprint'}},dueDate:'2026-08-20',attachments:[
|
||||
{{filename:'first.png',note:'Before'}},{{filename:'second.png',note:'After'}}
|
||||
]}},
|
||||
intent:'create-and-start',
|
||||
}};
|
||||
review.open(payload, trigger);
|
||||
payload.draft.title='Mutated outside';
|
||||
confirm.dispatch('click');
|
||||
confirm.dispatch('click');
|
||||
setImmediate(()=>process.stdout.write(JSON.stringify({{
|
||||
admissions:admitted.length,
|
||||
reviewedTitle:admitted[0].draft.title,
|
||||
evidence:evidence.children.map(item=>item.textContent),
|
||||
hidden:sheet.hidden,
|
||||
triggerFocused:trigger.focused || false,
|
||||
}})));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
|
||||
assert result == {
|
||||
"admissions": 1,
|
||||
"reviewedTitle": "Ship it",
|
||||
"evidence": ["1. first.png — Before", "2. second.png — After"],
|
||||
"hidden": True,
|
||||
"triggerFocused": True,
|
||||
}
|
||||
|
||||
|
||||
def test_review_lists_selected_blockers_in_filing_order():
|
||||
script = f"""
|
||||
const createReview=require({json.dumps(str(MODULE))});
|
||||
function target(){{const listeners={{}};return{{hidden:true,disabled:false,textContent:'',children:[],addEventListener:(n,f)=>listeners[n]=f,replaceChildren(...items){{this.children=items;}},focus(){{}}}};}}
|
||||
const blockerList=target();
|
||||
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),blockerList,
|
||||
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),
|
||||
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
|
||||
review.open({{draft:{{repository:'o/r',title:'Blocked',blockers:[
|
||||
{{repository:'o/api',number:7,title:'API ready'}},{{repository:'o/web',number:8,title:'Web ready'}}
|
||||
]}},intent:'create-and-assign'}},target());
|
||||
process.stdout.write(JSON.stringify(blockerList.children.map(item=>item.textContent)));
|
||||
"""
|
||||
assert run_node(script) == ["o/api #7 — API ready", "o/web #8 — Web ready"]
|
||||
|
||||
|
||||
def test_review_names_the_selected_initial_owner():
|
||||
script = f"""
|
||||
const createReview=require({json.dumps(str(MODULE))});
|
||||
function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}}
|
||||
const metadata=target();
|
||||
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),
|
||||
repository:target(),intent:target(),title:target(),body:target(),metadata,
|
||||
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
|
||||
review.open({{draft:{{repository:'o/r',title:'Delegate',assignee:'alex',assigneeName:'Alex'}},intent:'create-and-assign'}},target());
|
||||
process.stdout.write(JSON.stringify({{metadata:metadata.textContent}}));
|
||||
"""
|
||||
assert run_node(script)["metadata"].endswith("Owner: Alex (@alex)")
|
||||
|
||||
|
||||
def test_review_names_explicit_no_owner():
|
||||
script = f"""
|
||||
const createReview=require({json.dumps(str(MODULE))});
|
||||
function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}}
|
||||
const metadata=target(),intent=target();
|
||||
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),
|
||||
repository:target(),intent,title:target(),body:target(),metadata,
|
||||
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
|
||||
review.open({{draft:{{repository:'o/r',title:'Backlog capture',unassigned:true}},intent:'create-and-assign'}},target());
|
||||
process.stdout.write(JSON.stringify({{metadata:metadata.textContent,intent:intent.textContent}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["metadata"].endswith("Owner: No owner")
|
||||
assert output["intent"] == "Create unassigned"
|
||||
|
||||
|
||||
def test_review_names_the_repository_issue_type():
|
||||
script = f"""
|
||||
const createReview=require({json.dumps(str(MODULE))});
|
||||
function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}}
|
||||
const issueType=target();
|
||||
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),
|
||||
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),issueType,
|
||||
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
|
||||
review.open({{draft:{{repository:'o/r',title:'Crash',templateName:'Bug report'}},intent:'create-and-assign'}},target());
|
||||
process.stdout.write(JSON.stringify({{issueType:issueType.textContent}}));
|
||||
"""
|
||||
assert run_node(script)["issueType"] == "Bug report"
|
||||
|
||||
|
||||
def test_create_and_start_review_shows_estimate_and_projected_capacity():
|
||||
script = f"""
|
||||
const createReview=require({json.dumps(str(MODULE))});
|
||||
function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}}
|
||||
const metadata=target();
|
||||
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),
|
||||
repository:target(),intent:target(),title:target(),body:target(),metadata,
|
||||
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
|
||||
review.open({{draft:{{repository:'o/r',title:'Sized work',estimateMinutes:45,
|
||||
todayCapacity:{{remainingMinutes:60,projectedMinutes:15}}}},intent:'create-and-start'}},target());
|
||||
process.stdout.write(JSON.stringify({{metadata:metadata.textContent}}));
|
||||
"""
|
||||
metadata = run_node(script)["metadata"]
|
||||
assert "Estimate: 45 min" in metadata
|
||||
assert "Today after start: 15 min free" in metadata
|
||||
|
||||
|
||||
def test_escape_returns_to_the_unchanged_issue_form():
|
||||
script = f"""
|
||||
const createReview = require({json.dumps(str(MODULE))});
|
||||
function target() {{
|
||||
const listeners={{}};
|
||||
return {{hidden:true,disabled:false,textContent:'',children:[],
|
||||
addEventListener:(name,fn)=>listeners[name]=fn,
|
||||
replaceChildren(...items){{this.children=items;}},
|
||||
focus(){{this.focused=true;}}, dispatch:(name,event)=>listeners[name]?.(event)}};
|
||||
}}
|
||||
const listeners={{}}, documentRef={{
|
||||
createElement:()=>({{textContent:''}}),
|
||||
addEventListener:(name,fn)=>listeners[name]=fn,
|
||||
dispatch:(name,event)=>listeners[name]?.(event),
|
||||
}};
|
||||
const sheet=target(), trigger=target();
|
||||
const review=createReview({{
|
||||
sheet,confirmButton:target(),backButton:target(),evidenceList:target(),
|
||||
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),
|
||||
document:documentRef,onConfirm:async()=>{{}},
|
||||
}});
|
||||
const payload={{draft:{{repository:'o/r',title:'Original',body:'Note'}},intent:'create-and-assign'}};
|
||||
review.open(payload,trigger);
|
||||
documentRef.dispatch('keydown',{{key:'Escape',preventDefault(){{this.prevented=true;}}}});
|
||||
process.stdout.write(JSON.stringify({{hidden:sheet.hidden,focused:trigger.focused||false,title:payload.draft.title}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"hidden": True, "focused": True, "title": "Original"}
|
||||
|
||||
|
||||
def test_review_renders_exact_evidence_bytes_and_selects_the_preview():
|
||||
script = f"""
|
||||
const createReview = require({json.dumps(str(MODULE))});
|
||||
function target(tag='div') {{
|
||||
const listeners={{}}, attributes={{}};
|
||||
return {{tag,hidden:true,disabled:false,textContent:'',children:[],src:'',alt:'',
|
||||
addEventListener:(name,fn)=>listeners[name]=fn,
|
||||
dispatch:(name,event={{}})=>listeners[name]?.({{preventDefault(){{}},currentTarget:this,...event}}),
|
||||
replaceChildren(...items){{this.children=items;}}, appendChild(item){{this.children.push(item);}},
|
||||
setAttribute(name,value){{attributes[name]=String(value);}}, getAttribute:name=>attributes[name],
|
||||
focus(){{this.focused=true;}}}};
|
||||
}}
|
||||
const documentRef={{createElement:tag=>target(tag),addEventListener:()=>{{}}}};
|
||||
const evidence=target(), preview=target(), image=target('img');
|
||||
const position=target(), filename=target(), note=target(), empty=target();
|
||||
const created=[];
|
||||
const review=createReview({{
|
||||
sheet:target(),confirmButton:target(),backButton:target(),evidenceList:evidence,
|
||||
evidencePreview:preview,evidenceImage:image,evidencePosition:position,
|
||||
evidenceFilename:filename,evidenceNote:note,evidenceEmpty:empty,
|
||||
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),
|
||||
document:documentRef,createObjectURL:blob=>{{const url='blob:review-'+blob.size;created.push(url);return url;}},
|
||||
revokeObjectURL:()=>{{}},onConfirm:async()=>{{}},
|
||||
}});
|
||||
review.open({{draft:{{repository:'o/r',title:'Privacy check',attachments:[
|
||||
{{filename:'redacted.png',contentType:'image/png',blob:new Blob(['flattened-redaction']),note:'Secrets hidden'}},
|
||||
{{filename:'legacy.webp',contentType:'image/webp',data:'bGVnYWN5',note:'Expected result'}}
|
||||
]}},intent:'create-and-assign'}},target());
|
||||
const firstButton=evidence.children[0].children[0];
|
||||
const secondButton=evidence.children[1].children[0];
|
||||
secondButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
created,thumbnailSources:[firstButton.children[0].src,secondButton.children[0].src],
|
||||
labels:[firstButton.getAttribute('aria-label'),secondButton.getAttribute('aria-label')],
|
||||
selected:[firstButton.getAttribute('aria-pressed'),secondButton.getAttribute('aria-pressed')],
|
||||
preview:{{hidden:preview.hidden,src:image.src,alt:image.alt,position:position.textContent,
|
||||
filename:filename.textContent,note:note.textContent}},emptyHidden:empty.hidden,
|
||||
}}));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
|
||||
assert result == {
|
||||
"created": ["blob:review-19"],
|
||||
"thumbnailSources": [
|
||||
"blob:review-19",
|
||||
"data:image/webp;base64,bGVnYWN5",
|
||||
],
|
||||
"labels": [
|
||||
"Preview evidence 1 of 2: redacted.png",
|
||||
"Preview evidence 2 of 2: legacy.webp",
|
||||
],
|
||||
"selected": ["false", "true"],
|
||||
"preview": {
|
||||
"hidden": False,
|
||||
"src": "data:image/webp;base64,bGVnYWN5",
|
||||
"alt": "Evidence 2 of 2: legacy.webp",
|
||||
"position": "Evidence 2 of 2",
|
||||
"filename": "legacy.webp",
|
||||
"note": "Expected result",
|
||||
},
|
||||
"emptyHidden": True,
|
||||
}
|
||||
|
||||
|
||||
def test_failed_filing_keeps_preview_and_success_releases_blob_url_once():
|
||||
script = f"""
|
||||
const createReview = require({json.dumps(str(MODULE))});
|
||||
function target() {{
|
||||
const listeners={{}};
|
||||
return {{hidden:true,disabled:false,textContent:'',children:[],src:'',alt:'',
|
||||
addEventListener:(name,fn)=>listeners[name]=fn,
|
||||
dispatch:(name,event={{}})=>listeners[name]?.({{preventDefault(){{}},...event}}),
|
||||
replaceChildren(...items){{this.children=items;}},appendChild(item){{this.children.push(item);}},
|
||||
setAttribute(){{}},focus(){{this.focused=true;}}}};
|
||||
}}
|
||||
const documentRef={{createElement:()=>target(),addEventListener:()=>{{}}}};
|
||||
const sheet=target(), confirm=target(), trigger=target(), image=target(), preview=target();
|
||||
const revoked=[];
|
||||
let attempts=0;
|
||||
const review=createReview({{
|
||||
sheet,confirmButton:confirm,backButton:target(),evidenceList:target(),evidencePreview:preview,
|
||||
evidenceImage:image,evidencePosition:target(),evidenceFilename:target(),evidenceNote:target(),evidenceEmpty:target(),
|
||||
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),status:target(),
|
||||
document:documentRef,createObjectURL:blob=>'blob:attempt-'+blob.size,
|
||||
revokeObjectURL:url=>revoked.push(url),
|
||||
onConfirm:async()=>{{attempts+=1;if(attempts===1)throw new Error('Network unavailable');}},
|
||||
}});
|
||||
review.open({{draft:{{repository:'o/r',title:'Verify',attachment:{{filename:'one.png',blob:new Blob(['one'])}}}},intent:'create-and-assign'}},trigger);
|
||||
confirm.dispatch('click');
|
||||
setImmediate(()=>{{
|
||||
const afterFailure={{hidden:sheet.hidden,previewHidden:preview.hidden,src:image.src,disabled:confirm.disabled,revoked:[...revoked]}};
|
||||
confirm.dispatch('click');
|
||||
setImmediate(()=>process.stdout.write(JSON.stringify({{
|
||||
attempts,afterFailure,afterSuccess:{{hidden:sheet.hidden,triggerFocused:trigger.focused||false,revoked}}
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"attempts": 2,
|
||||
"afterFailure": {
|
||||
"hidden": False,
|
||||
"previewHidden": False,
|
||||
"src": "blob:attempt-3",
|
||||
"disabled": False,
|
||||
"revoked": [],
|
||||
},
|
||||
"afterSuccess": {
|
||||
"hidden": True,
|
||||
"triggerFocused": True,
|
||||
"revoked": ["blob:attempt-3"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_create_actions_review_the_complete_payload_before_durable_admission():
|
||||
root = Path(__file__).parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
bundle = (root / "src" / "frontend_bundle.py").read_text()
|
||||
|
||||
assert 'id="issue-filing-review" role="dialog" aria-modal="true"' in html
|
||||
assert 'id="issue-filing-review-repository"' in html
|
||||
assert 'id="issue-filing-review-intent"' in html
|
||||
assert 'id="issue-filing-review-title"' in html
|
||||
assert 'id="issue-filing-review-body"' in html
|
||||
assert 'id="issue-filing-review-evidence"' in html
|
||||
assert 'id="issue-filing-review-evidence-empty"' in html
|
||||
assert 'id="issue-filing-review-evidence-preview"' in html
|
||||
assert 'id="issue-filing-review-evidence-image"' in html
|
||||
assert 'id="issue-filing-review-evidence-position"' in html
|
||||
assert 'id="issue-filing-review-evidence-filename"' in html
|
||||
assert 'id="issue-filing-review-evidence-note"' in html
|
||||
assert 'id="back-to-issue-edit"' in html
|
||||
assert 'id="confirm-issue-filing"' in html
|
||||
assert '<script src="static/issue-filing-review.js"></script>' in html
|
||||
|
||||
submit_handler = dashboard.split(
|
||||
"qs('#create-issue-form').addEventListener('submit'", 1
|
||||
)[1].split("qs('#close-issue-sheet').addEventListener", 1)[0]
|
||||
assert "filingReview.open" in submit_handler
|
||||
assert "issueOutbox.enqueueDurably" not in submit_handler
|
||||
assert "async function admitReviewedIssue" in dashboard
|
||||
assert "evidencePreview: qs('#issue-filing-review-evidence-preview')" in dashboard
|
||||
assert "evidenceImage: qs('#issue-filing-review-evidence-image')" in dashboard
|
||||
assert "createObjectURL: blob => URL.createObjectURL(blob)" in dashboard
|
||||
assert "revokeObjectURL: url => URL.revokeObjectURL(url)" in dashboard
|
||||
assert "issueOutbox.enqueueDurably" in dashboard.split(
|
||||
"async function admitReviewedIssue", 1
|
||||
)[1].split("qs('#create-issue-form').addEventListener", 1)[0]
|
||||
|
||||
assert ".issue-filing-review" in css
|
||||
assert "max-height:100dvh" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert ".issue-filing-evidence-preview" in css
|
||||
assert ".issue-filing-evidence-selector" in css
|
||||
assert "min-height:44px" in css.split(".issue-filing-evidence-selector", 1)[1]
|
||||
assert '"static/issue-filing-review.js"' in bundle
|
||||
|
||||
|
||||
def test_duplicate_acknowledgement_preserves_the_selected_filing_intent():
|
||||
dashboard = (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert "let pendingIssueFilingIntent = 'create-and-assign';" in dashboard
|
||||
assert "if (event.submitter) pendingIssueFilingIntent" in dashboard
|
||||
assert "const intent = pendingIssueFilingIntent;" in dashboard
|
||||
assert "pendingIssueFilingIntent = 'create-and-assign';" in dashboard.split(
|
||||
"async function admitReviewedIssue", 1
|
||||
)[1].split("qs('#create-issue-form').addEventListener", 1)[0]
|
||||
+294
-1
@@ -44,6 +44,61 @@ confirmedLogin = 'alexander';
|
||||
assert len(output["calls"]) == 1
|
||||
|
||||
|
||||
def test_confirmed_delivery_returns_the_safe_related_plan_without_sending_it_to_gitea():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
let sentBody;
|
||||
const outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'first-operation',
|
||||
fetchJson:async (_url,options)=>{{sentBody=JSON.parse(options.body);return {{repository:'stackchain/dashboard',number:869,title:'Related source',assignees:[]}};}},
|
||||
}});
|
||||
outbox.enqueue({{
|
||||
repository:'stackchain/dashboard',title:'Related source',body:'Filled report',labelIds:[7],
|
||||
relatedDraft:{{repository:'stackchain/dashboard',title:'',body:'## Bug',labelIds:[7],templateId:'bug.yml',templateName:'Bug report',capturedBody:''}},
|
||||
}});
|
||||
outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result,sentBody}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["result"]["filings"] == [{
|
||||
"issue": {"repository": "stackchain/dashboard", "number": 869,
|
||||
"title": "Related source", "assignees": []},
|
||||
"relatedDraft": {"repository": "stackchain/dashboard", "title": "",
|
||||
"body": "## Bug", "labelIds": [7], "templateId": "bug.yml",
|
||||
"templateName": "Bug report", "capturedBody": ""},
|
||||
}]
|
||||
assert "relatedDraft" not in output["sentBody"]
|
||||
|
||||
|
||||
def test_editing_a_queued_issue_refreshes_its_related_plan():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||
let sequence=0;
|
||||
const outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=> 'timmy',createOperationId:()=> 'operation-' + (++sequence),
|
||||
fetchJson:async ()=>({{repository:'stackchain/dashboard',number:870,title:'Edited',assignees:[]}}),
|
||||
}});
|
||||
const queued=outbox.enqueue({{
|
||||
repository:'stackchain/dashboard',title:'Original',body:'One',
|
||||
relatedDraft:{{repository:'stackchain/dashboard',title:'',body:'Old scaffold',labelIds:[1]}},
|
||||
}});
|
||||
outbox.update(queued.id,{{
|
||||
repository:'stackchain/dashboard',title:'Edited',body:'Two',
|
||||
relatedDraft:{{repository:'stackchain/dashboard',title:'',body:'New scaffold',labelIds:[2]}},
|
||||
}});
|
||||
outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify(result.filings[0].relatedDraft)));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"repository": "stackchain/dashboard", "title": "", "body": "New scaffold",
|
||||
"labelIds": [2],
|
||||
}
|
||||
|
||||
|
||||
def test_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -71,6 +126,180 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
|
||||
assert output["stored"]["version"] == 3
|
||||
|
||||
|
||||
def test_issue_outbox_preserves_initial_owner_and_disables_start_for_teammate():
|
||||
script = f"""
|
||||
const createIssueOutbox=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=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'owner-outbox',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:18,assignees:['alex']}};}}}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Delegate',assignee:'alex',assigneeName:'Alex',completionIntent:'create-and-start'}});
|
||||
(async()=>{{await outbox.flush('timmy');process.stdout.write(JSON.stringify({{queued,calls}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["queued"]["assignee"] == "alex"
|
||||
assert output["queued"]["assigneeName"] == "Alex"
|
||||
assert "completionIntent" not in output["queued"]
|
||||
assert output["calls"] == [{
|
||||
"title": "Delegate", "body": "", "label_ids": [], "assignee": "alex"
|
||||
}]
|
||||
|
||||
|
||||
def test_issue_outbox_preserves_no_owner_and_delivers_unassigned_intent():
|
||||
script = f"""
|
||||
const createIssueOutbox=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=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'unowned-outbox',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:19,assignees:[]}};}}}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Backlog capture',unassigned:true,completionIntent:'create-and-start'}});
|
||||
(async()=>{{await outbox.flush('timmy');process.stdout.write(JSON.stringify({{queued,calls}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["unassigned"] is True
|
||||
assert "completionIntent" not in output["queued"]
|
||||
assert output["calls"] == [{
|
||||
"title": "Backlog capture", "body": "", "label_ids": [], "unassigned": True
|
||||
}]
|
||||
|
||||
|
||||
def test_editing_queued_issue_can_return_initial_owner_to_self():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let id=0;const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>String(++id)}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Delegate',assignee:'alex',assigneeName:'Alex'}});
|
||||
const updated=outbox.update(queued.id,{{...queued,assignee:'',assigneeName:'',completionIntent:'create-and-start'}});
|
||||
process.stdout.write(JSON.stringify(updated));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert "assignee" not in output
|
||||
assert "assigneeName" not in output
|
||||
assert output["completionIntent"] == "create-and-start"
|
||||
assert output["operationId"] != "1"
|
||||
|
||||
|
||||
def test_editing_queued_no_owner_to_self_rotates_delivery_and_allows_start():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let id=0;const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>String(++id)}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Backlog',unassigned:true}});
|
||||
const updated=outbox.update(queued.id,{{...queued,unassigned:false,completionIntent:'create-and-start',estimateMinutes:30}});
|
||||
process.stdout.write(JSON.stringify({{queued,updated}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["queued"]["unassigned"] is True
|
||||
assert "unassigned" not in output["updated"]
|
||||
assert output["updated"]["completionIntent"] == "create-and-start"
|
||||
assert output["updated"]["estimateMinutes"] == 30
|
||||
assert output["updated"]["operationId"] != output["queued"]["operationId"]
|
||||
|
||||
|
||||
def test_create_and_start_estimate_survives_outbox_delivery_and_completion():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'estimated-start',
|
||||
fetchJson:async()=>({{number:849,title:'Estimated work'}}),
|
||||
}});
|
||||
const queued=outbox.enqueue({{repository:'stackchain/dashboard',title:'Estimated work',
|
||||
completionIntent:'create-and-start',estimateMinutes:45}});
|
||||
(async()=>{{
|
||||
const delivered=await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{queued,completion:delivered.completions[0]}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["estimateMinutes"] == 45
|
||||
assert output["completion"]["estimateMinutes"] == 45
|
||||
|
||||
|
||||
def test_editing_queued_start_estimate_rotates_the_delivery_operation():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});const values=new Map();let sequence=0;
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-'+(++sequence)}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Sized',completionIntent:'create-and-start',estimateMinutes:30}});
|
||||
const updated=outbox.update(queued.id,{{...queued,estimateMinutes:60}});
|
||||
process.stdout.write(JSON.stringify({{queued,updated}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["updated"]["estimateMinutes"] == 60
|
||||
assert output["updated"]["operationId"] != output["queued"]["operationId"]
|
||||
|
||||
|
||||
def test_issue_outbox_preserves_bounded_ordered_evidence_bundle():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const image=name=>({{filename:name,contentType:'image/png',data:name,note:'Explain '+name}});
|
||||
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'bundle'}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Journey',attachments:['one','two','three','four','five'].map(image)}});
|
||||
let limit='';try{{outbox.enqueue({{repository:'o/r',title:'Too many',attachments:['1','2','3','4','5','6'].map(image)}});}}catch(error){{limit=error.message;}}
|
||||
process.stdout.write(JSON.stringify({{queued,list:outbox.list(),limit}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert [item["filename"] for item in output["queued"]["attachments"]] == [
|
||||
"one", "two", "three", "four", "five"
|
||||
]
|
||||
assert output["list"][0]["attachments"] == output["queued"]["attachments"]
|
||||
assert [item["note"] for item in output["queued"]["attachments"]] == [
|
||||
"Explain one", "Explain two", "Explain three", "Explain four", "Explain five"
|
||||
]
|
||||
assert output["limit"] == "You can attach up to 5 screenshots."
|
||||
|
||||
|
||||
def test_foreground_issue_delivery_posts_the_same_captioned_evidence_bundle():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map(),calls=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
let body=null;
|
||||
if(options.body instanceof FormData)body={{filename:options.body.get('file').name}};
|
||||
else if(options.body)body=JSON.parse(options.body);
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||
if(url.endsWith('/issues'))return{{number:827}};
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
return{{id:9}};
|
||||
}};
|
||||
let id=0;const outbox=createIssueOutbox({{storage,fetchJson,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-'+(++id)}});
|
||||
const image=(name,note)=>({{filename:name,contentType:'image/png',data:'eA==',note}});
|
||||
outbox.enqueue({{repository:'o/r',title:'Journey',attachments:[image('one.png','First'),image('two.png','Tap *Submit*')]}});
|
||||
(async()=>{{const result=await outbox.flush('timmy');process.stdout.write(JSON.stringify({{result,calls}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["result"]["confirmed"][0]["number"] == 827
|
||||
uploads = [call for call in output["calls"] if call["url"].endswith("/attachments")]
|
||||
assert [call["body"]["filename"] for call in uploads] == ["one.png", "two.png"]
|
||||
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
|
||||
assert len(comments) == 1
|
||||
assert comments[0]["body"]["body"] == (
|
||||
"**Screenshot 1 — First**\n\n\n\n"
|
||||
"**Screenshot 2 — Tap \\*Submit\\***\n\n"
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_issue_outbox_replaces_evidence_bundle_when_editing_a_queued_issue():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};const image=name=>({{filename:name,contentType:'image/png',data:name}});
|
||||
let sequence=0;const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>String(++sequence)}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Journey',attachments:[image('old-one'),image('old-two')]}});
|
||||
const updated=outbox.update(queued.id,{{...queued,attachments:[image('new-one'),image('new-two'),image('new-three')]}});
|
||||
process.stdout.write(JSON.stringify(updated));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert [item["filename"] for item in output["attachments"]] == ["new-one", "new-two", "new-three"]
|
||||
assert output["operationId"] != output["id"]
|
||||
|
||||
|
||||
|
||||
def test_issue_outbox_persists_a_validated_screenshot_with_the_capture():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -94,6 +323,27 @@ process.stdout.write(JSON.stringify(createIssueOutbox({{storage}}).list()[0]));
|
||||
}
|
||||
|
||||
|
||||
def test_issue_outbox_hydrates_durable_evidence_bundle_without_copying_blobs_to_localstorage():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
|
||||
let durable=null;
|
||||
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'bundle',backgroundSync:{{
|
||||
reconcile:async items=>{{durable=items[0];}},requestSync:async()=>{{}},get:async()=>durable,
|
||||
}}}});
|
||||
(async()=>{{const admitted=await outbox.enqueueDurably({{repository:'o/r',title:'Journey',attachments:['one','two'].map(image)}});
|
||||
const raw=values.get('stackchain.issue-outbox.v1');const hydrated=await outbox.hydrateForEdit(admitted.item.id);
|
||||
process.stdout.write(JSON.stringify({{raw,names:hydrated.attachments.map(x=>x.filename),sizes:hydrated.attachments.map(x=>x.blob.size)}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert '"stored":true' in output["raw"]
|
||||
assert '"blob"' not in output["raw"]
|
||||
assert output["names"] == ["one", "two"]
|
||||
assert output["sizes"] == [3, 3]
|
||||
|
||||
|
||||
|
||||
def test_issue_outbox_hydrates_a_metadata_only_screenshot_for_edit_without_copying_bytes_to_localstorage():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -260,6 +510,49 @@ const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{f
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_foreground_blocker_retry_checkpoints_each_relationship_without_recreating_issue():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const calls=[];let secondAttempts=0;
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'blocked-op',
|
||||
fetchJson:async(url,options={{}})=>{{
|
||||
const body=options.body ? JSON.parse(options.body) : null;
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||
if(url.endsWith('/issues'))return{{repository:'o/r',number:42,title:'Blocked work'}};
|
||||
if(body?.number===8 && secondAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
|
||||
return{{number:42,dependencies_available:true,dependencies:[body]}};
|
||||
}},
|
||||
}});
|
||||
const blockers=[
|
||||
{{repository:'o/api',number:7,title:'API ready'}},
|
||||
{{repository:'o/web',number:8,title:'Web ready'}},
|
||||
];
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Blocked work',body:'',blockers}});
|
||||
(async()=>{{
|
||||
await outbox.flush('timmy');const partial=outbox.list()[0];
|
||||
const result=await outbox.retry(queued.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{queued,partial,result,calls,remaining:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["blockers"] == [
|
||||
{"repository": "o/api", "number": 7, "title": "API ready"},
|
||||
{"repository": "o/web", "number": 8, "title": "Web ready"},
|
||||
]
|
||||
assert output["partial"]["deliveredIssue"]["number"] == 42
|
||||
assert output["partial"]["deliveredBlockers"] == 1
|
||||
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
|
||||
blocker_calls = [call for call in output["calls"] if call["url"].endswith("/blockers")]
|
||||
assert [call["body"]["number"] for call in blocker_calls] == [7, 8, 8]
|
||||
assert blocker_calls[0]["key"] == "blocked-op:blocker-0"
|
||||
assert blocker_calls[-1]["key"] == "blocked-op:blocker-1"
|
||||
assert output["result"]["confirmed"][0]["number"] == 42
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_replacing_a_partial_capture_screenshot_keeps_issue_and_restarts_upload_stage():
|
||||
script = f"""
|
||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||
@@ -730,7 +1023,7 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio
|
||||
|
||||
assert '<script src="static/issue-outbox.js"></script>' in html
|
||||
assert "const issueOutbox = createIssueOutbox({" in html
|
||||
assert "await issueOutbox.enqueueDurably(durableDraft)" in html
|
||||
assert "await issueOutbox.enqueueDurably(deliveryDraft)" in html
|
||||
assert "Saving for background delivery…" in html
|
||||
assert "Saved for next launch; background delivery unavailable." in html
|
||||
assert "throw new Error('Background Sync unavailable')" in html
|
||||
|
||||
@@ -132,3 +132,22 @@ process.stdout.write(JSON.stringify({{first, second, confirmations}}));
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"first": True, "second": False, "confirmations": 1}
|
||||
|
||||
|
||||
def test_picker_item_can_own_batch_confirmation_and_run_after_close():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const order=[];
|
||||
const picker=createLaterPicker({{
|
||||
now:() => new Date(2026,7,14,8,0),
|
||||
onConfirm:()=>{{order.push('global');return true;}},
|
||||
}});
|
||||
picker.open({{confirm:until=>() => order.push(until.toISOString())}},null,'search-batch');
|
||||
const saved=picker.submit('2026-08-15T09:00');
|
||||
process.stdout.write(JSON.stringify({{saved,order}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"saved": True,
|
||||
"order": ["2026-08-15T09:00:00.000Z"],
|
||||
}
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -325,11 +325,13 @@ async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch):
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_reuses_completed_snapshot_inside_freshness_window(monkeypatch):
|
||||
user_calls = 0
|
||||
user_started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def user():
|
||||
nonlocal user_calls
|
||||
user_calls += 1
|
||||
user_started.set()
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def blocked_repos():
|
||||
@@ -350,13 +352,8 @@ async def test_live_snapshot_reuses_completed_snapshot_inside_freshness_window(m
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
first = asyncio.create_task(main.live_snapshot())
|
||||
await asyncio.sleep(0)
|
||||
await user_started.wait()
|
||||
second = asyncio.create_task(main.live_snapshot())
|
||||
for _ in range(100):
|
||||
if user_calls:
|
||||
break
|
||||
await asyncio.to_thread(lambda: None)
|
||||
assert user_calls == 1
|
||||
|
||||
release.set()
|
||||
await asyncio.gather(first, second)
|
||||
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v101" in worker
|
||||
assert "stackchain-dashboard-shell-v102" in worker
|
||||
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v101" in worker
|
||||
assert "stackchain-dashboard-shell-v102" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v101" in worker
|
||||
assert "stackchain-dashboard-shell-v102" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -331,7 +331,7 @@ sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
|
||||
const close = new FakeElement();
|
||||
const badge = new FakeElement();
|
||||
const deadline = new FakeElement();
|
||||
const rows = Object.fromEntries(['today','agenda','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()]));
|
||||
const rows = Object.fromEntries(['today','agenda','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
|
||||
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
|
||||
const selected = [];
|
||||
const utilities = [];
|
||||
@@ -342,7 +342,7 @@ const dock = createDock({{
|
||||
observe() {{}},
|
||||
}});
|
||||
dock.start();
|
||||
dock.updateQueues({{today:2, agenda:5, attention:1, update:5, later:3, draft:4}});
|
||||
dock.updateQueues({{today:2, agenda:5, attention:1, update:5, filed:2, later:3, draft:4}});
|
||||
queues.click();
|
||||
const opened = sheet.open;
|
||||
rows.update.click();
|
||||
@@ -387,9 +387,9 @@ process.stdout.write(JSON.stringify({{
|
||||
"utilities": ["recaps:trigger"],
|
||||
"badge": "0 active",
|
||||
"populated": {
|
||||
"badge": "5 active",
|
||||
"badge": "6 active",
|
||||
"badgeHidden": False,
|
||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Later 3, Drafts 4; 5 active queues",
|
||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 6 active queues",
|
||||
"deadline": "5 due",
|
||||
"deadlineHidden": False,
|
||||
"agendaDue": "true",
|
||||
@@ -398,13 +398,13 @@ process.stdout.write(JSON.stringify({{
|
||||
"badge": "1 active",
|
||||
"badgeHidden": False,
|
||||
"deadlineHidden": True,
|
||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Later 0, Drafts 0; 1 active queue",
|
||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
|
||||
},
|
||||
"clearedBadgeHidden": True,
|
||||
"clearedDeadlineHidden": True,
|
||||
"clearedAgendaDue": None,
|
||||
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
|
||||
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "later": "0", "draft": "0", "recaps": "0"},
|
||||
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
|
||||
"updateLabel": "Updates, 0 unread conversations",
|
||||
"queueFocuses": 3,
|
||||
"columnState": None,
|
||||
@@ -439,6 +439,28 @@ process.stdout.write(JSON.stringify({{opened, empty, calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_uses_dedicated_filed_follow_up_flow():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
const launcher = createLauncher({{
|
||||
openFiled: () => {{ calls.push('filed-follow-up'); return 'opened-update'; }},
|
||||
selectFilter: name => calls.push('generic-filter:' + name),
|
||||
firstAction: () => {{ throw new Error('generic card launch must not run'); }},
|
||||
announce: message => calls.push('announce:' + message),
|
||||
}});
|
||||
const result = launcher.open('filed');
|
||||
process.stdout.write(JSON.stringify({{result, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": "opened-update",
|
||||
"calls": ["filed-follow-up"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
@@ -556,6 +578,11 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
assert 'data-mobile-queue="attention"' in html
|
||||
assert 'data-mobile-queue="update"' in html
|
||||
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
|
||||
assert 'data-mobile-queue="filed"' in html
|
||||
assert '<strong>Filed</strong><small>Issues you delegated</small>' in html
|
||||
assert 'data-mobile-queue-count="filed"' in html
|
||||
assert "openFiled: openFiledFollowUp" in html
|
||||
assert "filedFollowUpTarget(lastMyWork)" in html
|
||||
assert 'data-mobile-queue="later"' in html
|
||||
assert 'data-mobile-queue="draft"' in html
|
||||
assert 'data-mobile-queue="recaps"' in html
|
||||
|
||||
+470
-12
@@ -44,6 +44,34 @@ AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-sessi
|
||||
UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js"
|
||||
|
||||
|
||||
def test_filed_follow_up_target_preserves_queue_order_and_selects_update_reader():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const choose = buildMyWork.filedFollowUpTarget;
|
||||
const available = typeof choose === 'function';
|
||||
const quiet = {{key:'repo#1', is_filed:true, has_update:false}};
|
||||
const unread = {{key:'repo#2', is_filed:true, has_update:true, notification_id:22}};
|
||||
const unrelated = {{key:'repo#3', is_filed:false, has_update:true}};
|
||||
const results = available ? {{
|
||||
first:choose([unrelated, unread, quiet]),
|
||||
fallback:choose([unrelated, quiet, unread]),
|
||||
empty:choose([unrelated]),
|
||||
}} : null;
|
||||
process.stdout.write(JSON.stringify({{available, results}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"available": True,
|
||||
"results": {
|
||||
"first": {"kind": "update", "item": {"key": "repo#2", "is_filed": True, "has_update": True, "notification_id": 22}},
|
||||
"fallback": {"kind": "issue", "item": {"key": "repo#1", "is_filed": True, "has_update": False}},
|
||||
"empty": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_updates_rank_actionable_work_before_newer_routine_activity():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
@@ -1033,6 +1061,8 @@ def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
const inputs = [
|
||||
{{kind:'issue',repository:'stackchain/api',number:17}},
|
||||
{{kind:'filed',repository:'stackchain/api',number:18}},
|
||||
{{kind:'issue',repository:'stackchain/api',number:19,is_filed:true,is_assigned:false}},
|
||||
{{kind:'pull',repository:'stackchain/dashboard',number:42}},
|
||||
{{kind:'review',repository:'stackchain/dashboard',number:42}},
|
||||
{{kind:'update',notification_id:913}},
|
||||
@@ -1055,12 +1085,16 @@ process.stdout.write(JSON.stringify({{
|
||||
assert json.loads(result.stdout) == {
|
||||
"paths": [
|
||||
"#/my-work/issue/stackchain/api/17",
|
||||
"#/my-work/filed/stackchain/api/18",
|
||||
"#/my-work/filed/stackchain/api/19",
|
||||
"#/my-work/pull/stackchain/dashboard/42",
|
||||
"#/my-work/review/stackchain/dashboard/42",
|
||||
"#/my-work/update/913",
|
||||
],
|
||||
"parsed": [
|
||||
{"kind": "issue", "repository": "stackchain/api", "number": 17},
|
||||
{"kind": "filed", "repository": "stackchain/api", "number": 18},
|
||||
{"kind": "filed", "repository": "stackchain/api", "number": 19},
|
||||
{"kind": "pull", "repository": "stackchain/dashboard", "number": 42},
|
||||
{"kind": "review", "repository": "stackchain/dashboard", "number": 42},
|
||||
{"kind": "update", "notification_id": 913},
|
||||
@@ -1158,7 +1192,7 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.p
|
||||
def test_every_mobile_queue_route_survives_hydration_and_detail_back_navigation():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
const canonical = ['today', 'agenda', 'attention', 'updates', 'later', 'drafts'];
|
||||
const canonical = ['today', 'agenda', 'attention', 'filed', 'updates', 'later', 'drafts'];
|
||||
const parsed = canonical.map(name => [name, routes.parse('#/my-work/' + name)]);
|
||||
const listeners = {{}};
|
||||
const location = {{hash:'#/my-work/agenda'}};
|
||||
@@ -1194,6 +1228,7 @@ process.stdout.write(JSON.stringify({{parsed, calls, hash:location.hash}}));
|
||||
["today", {"kind": "queue", "filter": "today"}],
|
||||
["agenda", {"kind": "queue", "filter": "agenda"}],
|
||||
["attention", {"kind": "queue", "filter": "attention"}],
|
||||
["filed", {"kind": "queue", "filter": "filed"}],
|
||||
["updates", {"kind": "queue", "filter": "update"}],
|
||||
["later", {"kind": "queue", "filter": "later"}],
|
||||
["drafts", {"kind": "queue", "filter": "draft"}],
|
||||
@@ -1320,6 +1355,24 @@ const calls = [];
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_opens_delegated_filings_as_read_only_issue_sheets():
|
||||
html = await dashboard()
|
||||
|
||||
assert "else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);" in html
|
||||
assert "const readOnly = issueController.readOnly(item);" in html
|
||||
assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html
|
||||
assert "#issue-sheet.read-only .issue-comment-composer" in html
|
||||
assert "#issue-sheet.read-only #issue-planning" in html
|
||||
assert "#issue-sheet.read-only #issue-handoff" in html
|
||||
assert "#issue-sheet.read-only #release-issue" in html
|
||||
assert "#issue-sheet.read-only #close-issue" in html
|
||||
assert "#issue-sheet.read-only .detail-defer" in html
|
||||
assert "if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);" in html
|
||||
assert "qs('#load-older-issue-comments').hidden = true;" in html
|
||||
assert "readOnly ? 'Filed issue ready · read-only'" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share():
|
||||
html = await dashboard()
|
||||
@@ -1549,6 +1602,43 @@ process.stdout.write(JSON.stringify({{updated:updated.issues[0],original:origina
|
||||
}
|
||||
|
||||
|
||||
def test_issue_sheet_identifies_delegated_filings_as_read_only():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const controller = createIssueSheet({{fetchJson:async () => ({{}}), storage:null}});
|
||||
process.stdout.write(JSON.stringify([
|
||||
controller.readOnly({{kind:'filed',is_filed:true,is_assigned:false}}),
|
||||
controller.readOnly({{kind:'issue',is_filed:true,is_assigned:true}}),
|
||||
controller.readOnly({{kind:'issue',is_assigned:true}}),
|
||||
]));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
assert json.loads(result.stdout) == [True, False, False]
|
||||
|
||||
|
||||
def test_issue_sheet_loads_filed_items_through_read_only_access():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
const urls = [];
|
||||
const controller = createIssueSheet({{
|
||||
fetchJson: async url => {{ urls.push(url); return {{}}; }}, storage:null,
|
||||
}});
|
||||
Promise.all([
|
||||
controller.load({{kind:'issue',repository:'stackchain/api',number:17,is_assigned:true}}),
|
||||
controller.load({{kind:'filed',repository:'stackchain/api',number:18,is_filed:true,is_assigned:false}}),
|
||||
]).then(() => process.stdout.write(JSON.stringify(urls)));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
assert json.loads(result.stdout) == [
|
||||
"api/v1/repos/stackchain/api/issues/17/detail",
|
||||
"api/v1/repos/stackchain/api/issues/18/detail?access=filed",
|
||||
]
|
||||
|
||||
|
||||
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
@@ -1578,6 +1668,40 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||
assert output["results"][0]["available"] is True
|
||||
|
||||
|
||||
def test_related_issue_draft_reuses_plan_but_resets_issue_specific_work():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const related = createIssueSheet.buildRelatedDraft({{
|
||||
repository:'stackchain/dashboard', title:'Completed discovery', body:'Filled private report',
|
||||
labelIds:[7, 7, 9], milestoneId:4, dueDate:'2026-08-20',
|
||||
assignee:'alex', assigneeName:'Alexander', unassigned:false,
|
||||
templateId:'bug.yml', templateName:'Bug report', capturedBody:'Original notes',
|
||||
blockers:[{{repository:'stackchain/api',number:2}}], estimateMinutes:45,
|
||||
completionIntent:'create-and-start', operationId:'already-delivered',
|
||||
attachment:{{name:'secret.png'}}, duplicateAcknowledged:true,
|
||||
}}, {{id:'bug.yml', name:'Bug report', body:'## What happened?\\n\\n## Expected'}});
|
||||
process.stdout.write(JSON.stringify(related));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output == {
|
||||
"repository": "stackchain/dashboard",
|
||||
"title": "",
|
||||
"body": "## What happened?\n\n## Expected",
|
||||
"labelIds": [7, 9],
|
||||
"milestoneId": 4,
|
||||
"dueDate": "2026-08-20",
|
||||
"assignee": "alex",
|
||||
"assigneeName": "Alexander",
|
||||
"templateId": "bug.yml",
|
||||
"templateName": "Bug report",
|
||||
"capturedBody": "",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_release_rejects_response_that_still_assigns_current_user():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
@@ -1844,9 +1968,8 @@ async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
|
||||
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
|
||||
assert 'issueFilingMetadata.load(repository, selected)' in html
|
||||
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -3734,7 +3857,7 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"all": 3, "attention": 1, "issue": 1, "pull": 1, "review": 1, "update": 0
|
||||
"all": 3, "attention": 1, "filed": 0, "issue": 1, "pull": 1, "review": 1, "update": 0
|
||||
}
|
||||
|
||||
|
||||
@@ -4018,11 +4141,318 @@ async def test_mobile_issue_capture_requires_explicit_searchable_repository_sele
|
||||
assert "if (query.length < 2) {\n issueCapture.searchRepositories(query);" in html
|
||||
assert "renderIssueRepositoryResults(state.items)" in html
|
||||
assert "selectIssueCaptureRepository(repository)" in html
|
||||
assert "const hasRepository = Boolean(qs('#create-issue-repository').value);" in html
|
||||
assert "issueOwnerPicker.updateActions(Boolean(qs('#create-issue-repository').value)" in html
|
||||
assert '.create-issue-repository-result { min-height:44px;' in html
|
||||
assert '.create-issue-repository-picker { min-width:0;' in html
|
||||
|
||||
|
||||
def test_issue_capture_persists_bounded_blockers_and_searches_open_issues():
|
||||
script = f"""
|
||||
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
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 capture=createIssueCapture({{storage,fetchJson:async url=>{{calls.push(url);return{{items:[
|
||||
{{kind:'issue',state:'open',repository:'o/api',number:7,title:'API ready'}},
|
||||
{{kind:'pull',state:'open',repository:'o/web',number:8,title:'Not an issue'}},
|
||||
{{kind:'issue',state:'closed',repository:'o/old',number:9,title:'Closed'}}
|
||||
]}};}}}});
|
||||
const blockers=[
|
||||
{{repository:'o/api',number:7,title:'API ready'}},
|
||||
{{repository:'o/api',number:7,title:'duplicate'}},
|
||||
{{repository:'bad',number:2,title:'invalid'}},
|
||||
];
|
||||
capture.saveDraft({{repository:'o/r',title:'Blocked work',body:'',blockers}});
|
||||
(async()=>{{const found=await capture.searchBlockers('api');process.stdout.write(JSON.stringify({{draft:capture.loadDraft(),found,calls}}));}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
assert output["draft"]["blockers"] == [
|
||||
{"repository": "o/api", "number": 7, "title": "API ready"}
|
||||
]
|
||||
assert output["found"]["items"] == [
|
||||
{"kind": "issue", "state": "open", "repository": "o/api", "number": 7, "title": "API ready"}
|
||||
]
|
||||
assert output["calls"] == ["api/v1/search?q=api&limit=20"]
|
||||
|
||||
|
||||
def test_issue_capture_persists_selected_owner_and_sends_it_on_creation():
|
||||
script = f"""
|
||||
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
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 fetchJson=async (url,options={{}})=>{{calls.push({{url,body:options.body||''}});if(url.endsWith('/issue-assignees'))return [{{login:'alex',name:'Alex'}}];return {{number:18,assignees:['alex']}};}};
|
||||
const capture=createIssueCapture({{storage,fetchJson,createOperationId:()=>'owner-op'}});
|
||||
capture.saveDraft({{repository:'o/r',title:'Delegate',body:'Context',labelIds:[],assignee:'alex',assigneeName:'Alex'}});
|
||||
(async()=>{{const owners=await capture.loadOwners('o/r');const draft=capture.loadDraft();await capture.submit(draft);process.stdout.write(JSON.stringify({{owners,draft,calls}}));}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["owners"] == [{"login": "alex", "name": "Alex"}]
|
||||
assert output["draft"]["assignee"] == "alex"
|
||||
assert output["draft"]["assigneeName"] == "Alex"
|
||||
assert json.loads(output["calls"][-1]["body"])["assignee"] == "alex"
|
||||
|
||||
|
||||
def test_issue_capture_persists_and_sends_explicit_no_owner():
|
||||
script = f"""
|
||||
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
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 capture=createIssueCapture({{storage,createOperationId:()=>'unowned-op',fetchJson:async(url,options)=>{{calls.push(JSON.parse(options.body));return{{number:19,assignees:[]}};}}}});
|
||||
capture.saveDraft({{repository:'o/r',title:'Backlog capture',body:'Context',labelIds:[],unassigned:true}});
|
||||
(async()=>{{const draft=capture.loadDraft();await capture.submit(draft);process.stdout.write(JSON.stringify({{draft,calls}}));}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["draft"]["unassigned"] is True
|
||||
assert output["calls"] == [{
|
||||
"title": "Backlog capture", "body": "Context", "label_ids": [], "unassigned": True
|
||||
}]
|
||||
|
||||
|
||||
def test_issue_capture_persists_estimate_and_completion_intent_for_resumed_drafts():
|
||||
script = f"""
|
||||
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
const capture=createIssueCapture({{storage,fetchJson:async()=>{{}}}});
|
||||
capture.saveDraft({{
|
||||
repository:'o/r',title:'Planned work',body:'Context',labelIds:[3],
|
||||
estimateMinutes:45,completionIntent:'create-and-start',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(capture.loadDraft()));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["estimateMinutes"] == 45
|
||||
assert output["completionIntent"] == "create-and-start"
|
||||
|
||||
|
||||
def test_issue_capture_applies_and_switches_repository_templates_without_losing_authored_work():
|
||||
script = f"""
|
||||
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
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 fetchJson=async url=>{{calls.push(url);return [{{id:'bug-report',name:'Bug report',about:'Report a defect',title:'[Bug] ',body:'## Steps\\n1.',labels:['bug','unknown']}}];}};
|
||||
const capture=createIssueCapture({{storage,fetchJson}});
|
||||
(async()=>{{
|
||||
const templates=await capture.loadTemplates('o/r');
|
||||
const labels=[{{id:3,name:'bug'}},{{id:4,name:'P1'}}];
|
||||
const first=capture.applyTemplate({{repository:'o/r',title:'Camera crashes',body:'Captured on Android',labelIds:[4]}},templates[0],labels);
|
||||
const second=capture.applyTemplate(first,{{id:'feature',name:'Feature',body:'## Outcome',labels:[]}},labels);
|
||||
capture.saveDraft(second);
|
||||
process.stdout.write(JSON.stringify({{templates,first,second,reloaded:capture.loadDraft(),calls}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["calls"] == ["api/v1/repos/o/r/issue-templates"]
|
||||
assert output["first"]["title"] == "Camera crashes"
|
||||
assert output["first"]["body"] == "Captured on Android\n\n---\n\n## Steps\n1."
|
||||
assert output["first"]["labelIds"] == [4, 3]
|
||||
assert output["second"]["body"] == "Captured on Android\n\n---\n\n## Outcome"
|
||||
assert output["second"]["templateName"] == "Feature"
|
||||
assert output["reloaded"]["templateName"] == "Feature"
|
||||
assert output["reloaded"]["capturedBody"] == "Captured on Android"
|
||||
|
||||
|
||||
def test_initial_owner_picker_discards_an_older_same_repository_response():
|
||||
script = f"""
|
||||
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
let repository='o/a'; const pending=[];
|
||||
function select(){{const listeners={{}};return{{value:'',dataset:{{}},children:[],selectedOptions:[],
|
||||
replaceChildren(){{this.children=[];this.value='';this.selectedOptions=[];}},appendChild(option){{this.children.push(option);if(option.value===this.value)this.selectedOptions=[option];}},
|
||||
addEventListener:(name,fn)=>listeners[name]=fn}};}}
|
||||
const owner=select(),status={{textContent:''}},repo={{get value(){{return repository;}}}};
|
||||
const documentRef={{querySelector:id=>id==='#create-issue-assignee'?owner:id==='#create-issue-assignee-status'?status:repo,
|
||||
createElement:()=>({{value:'',textContent:'',dataset:{{}}}})}};
|
||||
const capture={{loadOwners:()=>new Promise(resolve=>pending.push(resolve))}};
|
||||
const picker=createIssueCapture.createOwnerPicker(capture,documentRef,()=>{{}});
|
||||
(async()=>{{picker.reset('o/a');const first=picker.load('o/a');repository='o/b';picker.reset('o/b');
|
||||
repository='o/a';picker.reset('o/a');const latest=picker.load('o/a');pending[1]([{{login:'new',name:'New'}}]);await latest;
|
||||
pending[0]([{{login:'old',name:'Old'}}]);await first;
|
||||
process.stdout.write(JSON.stringify(owner.children.map(option=>option.value)));}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
assert output == ["", "__unassigned__", "new"]
|
||||
|
||||
|
||||
def test_initial_owner_picker_exposes_no_owner_and_disables_start():
|
||||
script = f"""
|
||||
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
function select(){{return{{value:'',dataset:{{}},children:[],selectedOptions:[],
|
||||
replaceChildren(){{this.children=[];this.value='';this.selectedOptions=[];}},
|
||||
appendChild(option){{this.children.push(option);}},addEventListener:()=>{{}}}};}}
|
||||
const owner=select(),status={{textContent:''}},repo={{value:'o/r'}};
|
||||
const submit={{disabled:false,textContent:''}},start={{disabled:false,title:''}};
|
||||
const documentRef={{querySelector:id=>({{
|
||||
'#create-issue-assignee':owner,'#create-issue-assignee-status':status,
|
||||
'#create-issue-repository':repo,'#submit-new-issue':submit,'#create-and-start-issue':start,
|
||||
}}[id]),createElement:()=>({{value:'',textContent:'',dataset:{{}}}})}};
|
||||
const picker=createIssueCapture.createOwnerPicker({{loadOwners:async()=>[]}},documentRef,()=>{{}});
|
||||
picker.reset('o/r',{{unassigned:true}});owner.value='__unassigned__';
|
||||
picker.updateActions(true,false,true);
|
||||
process.stdout.write(JSON.stringify({{options:owner.children.map(option=>[option.value,option.textContent]),fields:picker.fields(),submit,start}}));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["options"] == [["", "Me"], ["__unassigned__", "No owner"]]
|
||||
assert output["fields"] == {"assignee": "", "assigneeName": "", "unassigned": True}
|
||||
assert output["submit"]["textContent"] == "Create unassigned"
|
||||
assert output["start"]["disabled"] is True
|
||||
assert "No owner" in output["start"]["title"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_capture_selects_blockers_without_starting_blocked_work():
|
||||
html = await dashboard()
|
||||
assert 'id="create-issue-blocker-search" type="search"' in html
|
||||
assert 'id="create-issue-blocker-results" role="listbox"' in html
|
||||
assert 'id="create-issue-blocker-selected"' in html
|
||||
assert 'id="create-issue-blocker-status" class="small" aria-live="polite"' in html
|
||||
assert "issueCapture.searchBlockers(query)" in html
|
||||
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
|
||||
assert "renderIssueCaptureBlockers(captureDraft.blockers || [])" in html
|
||||
assert "issueCaptureBlockers.length > 0, createAndStart.available()" in html
|
||||
assert ".create-issue-blocker-result" in html
|
||||
assert "min-height:44px" in html.split(".create-issue-blocker-result", 1)[1]
|
||||
assert "@media(max-width:320px)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_capture_lazily_selects_an_initial_owner():
|
||||
html = await dashboard()
|
||||
assert 'id="create-issue-assignee"' in html
|
||||
assert '<option value="">Me</option>' in html
|
||||
assert '<option value="__unassigned__">No owner</option>' in html
|
||||
assert 'id="create-issue-assignee-status" class="small" aria-live="polite"' in html
|
||||
assert "createIssueCapture.createOwnerPicker(issueCapture, document," in html
|
||||
assert "() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });" in html
|
||||
assert "issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers" in html
|
||||
assert "issueOwnerPicker.updateActions(" in html
|
||||
assert '.create-issue-owner select { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_filing_exposes_repository_issue_types_without_blocking_blank_filing():
|
||||
html = await dashboard()
|
||||
source = CREATE_ISSUE_SHEET.read_text()
|
||||
assert 'id="create-issue-template"' in html
|
||||
assert '<option value="">Blank issue</option>' in html
|
||||
assert 'id="create-issue-template-status" class="small" aria-live="polite"' in html
|
||||
assert 'templatePicker.render(repository, metadata.templates' in source
|
||||
assert 'issueCapture.bindTemplatePicker(' in html
|
||||
assert 'id="issue-filing-review-template"' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_every_mobile_repository_selection_uses_the_resilient_metadata_loader():
|
||||
html = await dashboard()
|
||||
source = CREATE_ISSUE_SHEET.read_text()
|
||||
|
||||
assert "async function loadIssueFilingMetadata(repository" in html
|
||||
assert "issueCapture.bindFilingMetadata(" in html
|
||||
assert "issueFilingMetadata.load(repository, selected)" in html
|
||||
assert "const metadata = await loadFilingMetadata(repository)" in source
|
||||
assert "renderLabels(repository, metadata.labels" in source
|
||||
assert "renderMilestones(repository, metadata.milestones" in source
|
||||
assert "templatePicker.render(repository, metadata.templates" in source
|
||||
assert html.count("loadIssueFilingMetadata(") == 5
|
||||
assert "loadIssueLabels(repository);\n loadIssueMilestones(repository);" not in html
|
||||
assert "loadIssueLabels(event.target.value).then" not in html
|
||||
assert "loadIssueLabels(qs('#create-issue-repository').value" not in html
|
||||
|
||||
|
||||
def test_issue_capture_loads_all_filing_metadata_in_one_section_isolated_request():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const calls = [];
|
||||
const capture = createIssueCapture({{
|
||||
storage:{{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
|
||||
fetchJson:url=>{{
|
||||
calls.push(url);
|
||||
return Promise.resolve({{
|
||||
labels:{{available:true,items:[{{id:3,name:'P0'}}]}},
|
||||
milestones:{{available:false,items:[],error:'Milestones could not be loaded.'}},
|
||||
templates:{{available:true,items:[{{id:'bug',name:'Bug report'}}]}},
|
||||
}});
|
||||
}},
|
||||
}});
|
||||
capture.loadFilingMetadata('stackchain/api').then(result=>
|
||||
process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output == {
|
||||
"calls": ["api/v1/repos/stackchain/api/issue-filing-metadata"],
|
||||
"result": {
|
||||
"status": "ready",
|
||||
"repository": "stackchain/api",
|
||||
"labels": {"available": True, "items": [{"id": 3, "name": "P0"}]},
|
||||
"milestones": {
|
||||
"available": False,
|
||||
"items": [],
|
||||
"error": "Milestones could not be loaded.",
|
||||
},
|
||||
"templates": {
|
||||
"available": True,
|
||||
"items": [{"id": "bug", "name": "Bug report"}],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_rejects_filing_metadata_from_a_stale_repository_request():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const releases = {{}};
|
||||
const capture = createIssueCapture({{
|
||||
storage:{{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
|
||||
fetchJson:url=>new Promise(resolve=>{{ releases[url] = resolve; }}),
|
||||
}});
|
||||
const first = capture.loadFilingMetadata('stackchain/old');
|
||||
const second = capture.loadFilingMetadata('stackchain/current');
|
||||
const payload = {{
|
||||
labels:{{available:true,items:[]}}, milestones:{{available:true,items:[]}},
|
||||
templates:{{available:true,items:[]}},
|
||||
}};
|
||||
releases['api/v1/repos/stackchain/current/issue-filing-metadata'](payload);
|
||||
Promise.resolve().then(()=>{{
|
||||
releases['api/v1/repos/stackchain/old/issue-filing-metadata'](payload);
|
||||
return Promise.all([first, second]);
|
||||
}}).then(results=>process.stdout.write(JSON.stringify(results)));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == [
|
||||
{"status": "stale", "repository": "stackchain/old"},
|
||||
{
|
||||
"status": "ready", "repository": "stackchain/current",
|
||||
"labels": {"available": True, "items": []},
|
||||
"milestones": {"available": True, "items": []},
|
||||
"templates": {"available": True, "items": []},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||
html = await dashboard()
|
||||
@@ -4587,7 +5017,7 @@ process.stdout.write(JSON.stringify({{
|
||||
assert output["updates"][1]["kind"] == "update"
|
||||
assert output["updates"][1]["update_reason"] == ""
|
||||
assert output["counts"] == {
|
||||
"all": 2, "attention": 2, "issue": 1, "pull": 0, "review": 0, "update": 2
|
||||
"all": 2, "attention": 2, "filed": 0, "issue": 1, "pull": 0, "review": 0, "update": 2
|
||||
}
|
||||
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
|
||||
|
||||
@@ -6309,7 +6739,7 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
|
||||
assert 'createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage })' in html
|
||||
assert 'lastMyWork = buildMyWork(lastContextSnapshot);' in html
|
||||
assert 'openRoutedWork(created' in html
|
||||
assert 'issueCapture.loadLabels(repository)' in html
|
||||
assert 'issueCapture.bindFilingMetadata({' in html
|
||||
assert "input[name=\"create-issue-label\"]:checked" in html
|
||||
assert '.create-issue-label-option' in html and 'min-height:44px' in html
|
||||
|
||||
@@ -6333,9 +6763,12 @@ async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
|
||||
assert "issueCapture.findDuplicates(draft)" in html
|
||||
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html
|
||||
assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html
|
||||
assert html.index("issueCapture.needsDuplicateAcknowledgement(captureDraft)") < html.index(
|
||||
"issueOutbox.enqueueDurably(durableDraft)"
|
||||
)
|
||||
submit_handler = html.split("qs('#create-issue-form').addEventListener('submit'", 1)[1].split(
|
||||
"qs('#close-issue-sheet').addEventListener", 1
|
||||
)[0]
|
||||
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in submit_handler
|
||||
assert "filingReview.open" in submit_handler
|
||||
assert "issueOutbox.enqueueDurably" not in submit_handler
|
||||
assert ".create-issue-duplicate-card" in html
|
||||
assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html
|
||||
assert "overflow-wrap:anywhere" in html
|
||||
@@ -6553,7 +6986,7 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
assert 'data-work-count="review"' in html
|
||||
assert 'data-work-count="update"' in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert "['all', 'today', 'agenda', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert "['all', 'today', 'agenda', 'attention', 'filed', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert 'data-work-count="draft"' in html
|
||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||
@@ -7304,3 +7737,28 @@ first.submit(item, payload).catch(() => {{
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
assert json.loads(result.stdout) == {"calls": ["review-op-185", "review-op-185"], "keys": []}
|
||||
|
||||
def test_filed_queue_contains_authored_issues_and_deduplicates_self_assigned_filings():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
"issues": [
|
||||
{"id": 1, "repository": "stackchain/api", "number": 1, "title": "Delegated", "assignees": ["alex"], "work_reasons": ["created_by_me"]},
|
||||
{"id": 2, "repository": "stackchain/api", "number": 2, "title": "Mine", "assignees": ["timmy"], "work_reasons": ["created_by_me"]},
|
||||
{"id": 3, "repository": "stackchain/api", "number": 3, "title": "Assigned only", "assignees": ["timmy"]},
|
||||
],
|
||||
"pull_requests": [],
|
||||
}
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = buildMyWork({json.dumps(payload)});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
filed: buildMyWork.filterMyWork(items, 'filed').map(item => item.number).sort((a,b) => a-b),
|
||||
count: buildMyWork.countMyWork(items).filed,
|
||||
identities: items.map(item => item.key).sort(),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
assert json.loads(result.stdout) == {
|
||||
"filed": [1, 2], "count": 2,
|
||||
"identities": ["stackchain/api#1", "stackchain/api#2", "stackchain/api#3"],
|
||||
}
|
||||
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -112,6 +112,7 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
|
||||
== 64 * 1024
|
||||
)
|
||||
assert main.request_body_limit("GET", "/api/v1/context") is None
|
||||
assert main.request_body_limit("PUT", "/api/v1/unfiled-drafts") == 17 * 1024 * 1024
|
||||
assert (
|
||||
main.request_body_limit(
|
||||
"POST", "/api/v1/repos/stackchain/project/issues/17/attachments"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||
|
||||
|
||||
def view(view_id="release", name="Release queue", query="mobile", **scope):
|
||||
return {
|
||||
"id": view_id,
|
||||
"name": name,
|
||||
"query": query,
|
||||
"kind": scope.get("kind", "issue"),
|
||||
"state": scope.get("state", "open"),
|
||||
"repository": scope.get("repository", "stackchain/stackchain-dashboard"),
|
||||
}
|
||||
|
||||
|
||||
def test_saved_searches_are_revisioned_ordered_and_account_scoped(tmp_path):
|
||||
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3")
|
||||
|
||||
created = store.replace(" Timmy ", 0, [view(), view("reviews", "My reviews", "review", kind="pull")])
|
||||
|
||||
assert created == {"revision": 1, "views": [view(), view("reviews", "My reviews", "review", kind="pull")]}
|
||||
assert store.get("timmy") == created
|
||||
assert store.get("alexander") == {"revision": 0, "views": []}
|
||||
|
||||
with pytest.raises(SavedSearchConflict) as conflict:
|
||||
store.replace("timmy", 0, [view(name="Stale overwrite")])
|
||||
assert conflict.value.snapshot == created
|
||||
|
||||
|
||||
def test_saved_searches_validate_and_bound_the_synced_collection(tmp_path):
|
||||
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3", limit=2)
|
||||
|
||||
with pytest.raises(ValueError, match="limited to 2"):
|
||||
store.replace("timmy", 0, [view("one"), view("two"), view("three")])
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
store.replace("timmy", 0, [view("same"), view("same")])
|
||||
|
||||
invalid = [
|
||||
({**view(), "name": " "}, "name is required"),
|
||||
({**view(), "query": "x"}, "query must be between"),
|
||||
({**view(), "kind": "commit"}, "kind is invalid"),
|
||||
({**view(), "state": "merged"}, "state is invalid"),
|
||||
({**view(), "repository": "not-a-repository"}, "repository is invalid"),
|
||||
]
|
||||
for candidate, message in invalid:
|
||||
with pytest.raises(ValueError, match=message):
|
||||
store.replace("timmy", 0, [candidate])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_search_api_is_authenticated_csrf_protected_no_store_and_conflict_safe(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_SAVED_SEARCH_DB", str(tmp_path / "searches.sqlite3"))
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||
forbidden = await client.put(
|
||||
"/api/v1/saved-searches", json={"revision": 0, "views": [view()]}
|
||||
)
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
saved = await client.put(
|
||||
"/api/v1/saved-searches", json={"revision": 0, "views": [view()]}, headers=headers
|
||||
)
|
||||
stale = await client.put(
|
||||
"/api/v1/saved-searches",
|
||||
json={"revision": 0, "views": [view(name="Overwrite")]},
|
||||
headers=headers,
|
||||
)
|
||||
fetched = await client.get("/api/v1/saved-searches")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert saved.status_code == 200
|
||||
assert saved.json() == {"revision": 1, "views": [view()]}
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["detail"] == {
|
||||
"message": "Saved searches changed on another device.",
|
||||
"snapshot": saved.json(),
|
||||
}
|
||||
assert fetched.json() == saved.json()
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
@@ -0,0 +1,105 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SAVED_SEARCHES = Path(__file__).parents[1] / "frontend" / "saved-searches.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
|
||||
|
||||
def test_saved_search_controller_loads_saves_opens_renames_and_deletes():
|
||||
script = f"""
|
||||
const createSavedSearches=require({json.dumps(str(SAVED_SEARCHES))});
|
||||
const requests=[]; const opened=[]; const states=[];
|
||||
let remote={{revision:0,views:[]}}; let sequence=0;
|
||||
const controller=createSavedSearches({{
|
||||
fetchJson:async (_url,options={{}})=>{{
|
||||
requests.push(options.method||'GET');
|
||||
if(!options.method)return remote;
|
||||
const body=JSON.parse(options.body);remote={{revision:remote.revision+1,views:body.views}};return remote;
|
||||
}},
|
||||
createId:()=> 'view-'+(++sequence),
|
||||
onOpen:view=>opened.push(view),
|
||||
onState:state=>states.push(state),
|
||||
}});
|
||||
(async()=>{{
|
||||
await controller.load();
|
||||
await controller.save('Release queue',{{query:'mobile',kind:'issue',state:'open',repository:'stackchain/dashboard'}});
|
||||
controller.open('view-1');
|
||||
await controller.rename('view-1','Morning release queue');
|
||||
await controller.remove('view-1');
|
||||
process.stdout.write(JSON.stringify({{requests,opened,snapshot:controller.snapshot(),statuses:states.map(x=>x.status)}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"requests": ["GET", "PUT", "PUT", "PUT"],
|
||||
"opened": [{
|
||||
"id": "view-1",
|
||||
"name": "Release queue",
|
||||
"query": "mobile",
|
||||
"kind": "issue",
|
||||
"state": "open",
|
||||
"repository": "stackchain/dashboard",
|
||||
}],
|
||||
"snapshot": {"revision": 3, "views": []},
|
||||
"statuses": ["loading", "ready", "saving", "ready", "saving", "ready", "saving", "ready"],
|
||||
}
|
||||
|
||||
|
||||
def test_saved_search_controller_adopts_conflict_and_keeps_search_usable_on_failure():
|
||||
script = f"""
|
||||
const createSavedSearches=require({json.dumps(str(SAVED_SEARCHES))});
|
||||
const states=[];let mode='conflict';
|
||||
const server={{revision:4,views:[{{id:'remote',name:'Remote',query:'review',kind:'pull',state:'open',repository:''}}]}};
|
||||
const controller=createSavedSearches({{
|
||||
fetchJson:async (_url,options={{}})=>{{
|
||||
if(!options.method)return {{revision:3,views:[]}};
|
||||
if(mode==='conflict'){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:server}}}};throw error;}}
|
||||
throw new Error('offline');
|
||||
}},createId:()=> 'local',onOpen:()=>{{}},onState:state=>states.push([state.status,state.message||'']),
|
||||
}});
|
||||
(async()=>{{
|
||||
await controller.load();
|
||||
try{{await controller.save('Local',{{query:'mobile',kind:'all',state:'all'}})}}catch(_error){{}}
|
||||
mode='offline';
|
||||
try{{await controller.remove('remote')}}catch(_error){{}}
|
||||
process.stdout.write(JSON.stringify({{snapshot:controller.snapshot(),states}}));
|
||||
}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["snapshot"] == {
|
||||
"revision": 4,
|
||||
"views": [{
|
||||
"id": "remote", "name": "Remote", "query": "review",
|
||||
"kind": "pull", "state": "open", "repository": "",
|
||||
}],
|
||||
}
|
||||
assert result["states"][-1] == ["error", "Saved searches could not sync. Search still works."]
|
||||
assert ["conflict", "Saved searches changed on another device."] in result["states"]
|
||||
|
||||
|
||||
def test_mobile_search_renders_synced_saved_view_controls_and_runtime_wiring():
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
html = dashboard_bundle_text()
|
||||
css = (SAVED_SEARCHES.parent / "dashboard.css").read_text()
|
||||
dashboard = (SAVED_SEARCHES.parent / "dashboard.js").read_text()
|
||||
|
||||
assert '<section class="saved-searches" aria-labelledby="saved-searches-heading">' in html
|
||||
assert 'id="saved-search-name"' in html
|
||||
assert 'id="save-current-search"' in html
|
||||
assert 'id="saved-search-list"' in html
|
||||
assert 'id="saved-search-status"' in html
|
||||
assert "createSavedSearches.mount(" in dashboard
|
||||
saved_searches = SAVED_SEARCHES.read_text()
|
||||
assert "search.setQuery('')" in saved_searches
|
||||
assert "applyScope(view)" in saved_searches
|
||||
assert "search.setQuery(view.query)" in saved_searches
|
||||
assert ").load();" in dashboard
|
||||
assert "static/saved-searches.js" in (SAVED_SEARCHES.parent.parent / "src" / "frontend_bundle.py").read_text()
|
||||
assert ".saved-search-action { min-height:44px;" in css
|
||||
@@ -69,6 +69,20 @@ Promise.all([
|
||||
assert result["results"][2] == "This issue is no longer available to assign."
|
||||
|
||||
|
||||
def test_release_planning_requires_selected_issues_from_one_repository():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const flow=createSearchBatchPlan();
|
||||
flow.start();
|
||||
flow.toggle({{kind:'issue',state:'open',repository:'stackchain/dashboard',number:815}});
|
||||
const one=flow.releaseRepository();
|
||||
flow.toggle({{kind:'issue',state:'open',repository:'stackchain/api',number:17}});
|
||||
process.stdout.write(JSON.stringify({{one,mixed:flow.releaseRepository()}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"one": "stackchain/dashboard", "mixed": None}
|
||||
|
||||
|
||||
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
@@ -79,13 +93,25 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
||||
assert 'id="select-search-results"' in html
|
||||
assert 'id="search-batch-actions"' in html
|
||||
assert 'id="queue-selected-search-results"' in html
|
||||
assert 'id="defer-selected-search-results"' in html
|
||||
assert 'id="plan-selected-search-results"' in html
|
||||
assert 'id="search-release-review"' in html
|
||||
assert 'id="search-release-milestone"' in html
|
||||
assert 'id="search-release-due-date"' in html
|
||||
assert 'id="resume-search-batch"' in html
|
||||
assert 'src="static/search-batch-plan.js"' in html
|
||||
assert "mountSearchBatchPlanning(" in dashboard
|
||||
assert "escapeHtml, escAttr, laterWork, laterPicker" in dashboard
|
||||
assert "journalName:'search-today-batch'" in controller
|
||||
assert "journalName:'search-later-batch'" in controller
|
||||
assert "journalName:'search-release-batch'" in controller
|
||||
assert "autoMount:false" in controller
|
||||
assert "plan.toggle(result)" in controller
|
||||
assert "processor.run(plan.snapshot().items)" in controller
|
||||
assert "processor.run(plan.snapshot().items, estimateValues())" in controller
|
||||
assert "timeBudget:()" in controller
|
||||
assert "persistEstimate" in controller
|
||||
assert 'id="search-batch-estimate-review"' in html
|
||||
assert 'data-search-batch-estimate' in controller
|
||||
assert "processor.resume()" in controller
|
||||
assert 'aria-label="Select ' in controller
|
||||
assert "result.kind === 'issue' && result.state === 'open'" in controller
|
||||
@@ -93,4 +119,240 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
||||
assert "position:sticky" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert ".cmd-select-result" in css and "min-height:44px" in css
|
||||
assert ".search-release-review" in css and "100dvh" in css
|
||||
assert ".search-release-actions" in css and "env(safe-area-inset-bottom)" in css
|
||||
assert "BASE + 'static/search-batch-plan.js'" in worker
|
||||
|
||||
|
||||
def test_selected_search_issues_choose_one_future_time_before_durable_batch_defer():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const listeners={{}};
|
||||
const elements={{}};
|
||||
for (const id of [
|
||||
'search-batch-actions','select-search-results','queue-selected-search-results',
|
||||
'defer-selected-search-results','plan-selected-search-results','search-selection-status','cmd-search-action-status',
|
||||
'resume-search-batch','open-palette','cmd-results','cancel-search-selection',
|
||||
'search-batch-estimate-review','search-batch-estimate-summary','search-batch-estimate-list',
|
||||
'cancel-search-batch-estimates','confirm-search-batch-estimates','search-release-review',
|
||||
'search-release-summary','search-release-milestone','search-release-due-date','search-release-list',
|
||||
'cancel-search-release','confirm-search-release'
|
||||
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',
|
||||
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
|
||||
const document={{querySelector:selector=>elements[selector] || null,querySelectorAll:()=>[]}};
|
||||
const processorOptions=[];
|
||||
const runs=[];
|
||||
const processors=[];
|
||||
const batchFactory=options=>{{
|
||||
processorOptions.push(options);
|
||||
const processor={{
|
||||
run:(items,estimates,context)=>{{runs.push({{items,estimates,context}});return Promise.resolve();}},
|
||||
resume:()=>Promise.resolve(),pending:()=>0,
|
||||
}};
|
||||
processors.push(processor);
|
||||
return processor;
|
||||
}};
|
||||
const planning={{capacity_minutes:null,estimates:{{}}}};
|
||||
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,
|
||||
replacePlanning:()=>{{}}}};
|
||||
const laterCalls=[];
|
||||
const laterWork={{defer:(item,until)=>{{laterCalls.push([item.number,until]);return 'deferred';}}}};
|
||||
let pickerRequest=null;
|
||||
const laterPicker={{open:(payload,trigger,context)=>{{pickerRequest={{payload,trigger,context}};return true;}}}};
|
||||
const item={{kind:'issue',state:'open',repository:'stackchain/dashboard',number:813,title:'Batch defer'}};
|
||||
const mounted=createSearchBatchPlan.mount(
|
||||
document,batchFactory,todayWork,()=> 'timmy',()=>Promise.resolve(),()=>'',()=>Promise.resolve('queued'),
|
||||
{{enqueueConfiguration:()=>{{}},flush:()=>{{}}}},value=>({{...value,accepted:true}}),()=>item,()=>{{}},
|
||||
value=>value,value=>value,laterWork,laterPicker
|
||||
);
|
||||
mounted.plan.start();
|
||||
mounted.plan.toggle(item);
|
||||
listeners['defer-selected-search-results:click']({{currentTarget:elements['#defer-selected-search-results']}});
|
||||
const before={{runs:runs.slice(),count:pickerRequest.payload.items.length,context:pickerRequest.context,journals:processorOptions.map(value=>value.journalName)}};
|
||||
pickerRequest.payload.confirm(new Date('2026-08-15T09:00:00.000Z'))();
|
||||
const laterResult=processorOptions[1].queue({{...item,assigned_to_me:true}},{{until:'2026-08-15T09:00:00.000Z'}});
|
||||
process.stdout.write(JSON.stringify({{before,runs,laterResult,laterCalls}}));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["before"] == {
|
||||
"runs": [],
|
||||
"count": 1,
|
||||
"context": "search-batch",
|
||||
"journals": ["search-today-batch", "search-later-batch", "search-release-batch"],
|
||||
}
|
||||
assert result["runs"] == [{
|
||||
"items": [{
|
||||
"kind": "issue", "state": "open", "repository": "stackchain/dashboard",
|
||||
"number": 813, "title": "Batch defer",
|
||||
}],
|
||||
"estimates": {},
|
||||
"context": {"until": "2026-08-15T09:00:00.000Z"},
|
||||
}]
|
||||
assert result["laterResult"] == "queued"
|
||||
assert result["laterCalls"] == [[813, "2026-08-15T09:00:00.000Z"]]
|
||||
|
||||
|
||||
def test_search_batch_reviews_time_estimates_before_any_assignment_and_persists_queued_estimates():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const listeners={{}};
|
||||
const elements={{}};
|
||||
for (const id of [
|
||||
'search-batch-actions','select-search-results','queue-selected-search-results',
|
||||
'defer-selected-search-results','plan-selected-search-results',
|
||||
'search-selection-status','cmd-search-action-status','resume-search-batch',
|
||||
'open-palette','cmd-results','cancel-search-selection','search-batch-estimate-review',
|
||||
'search-batch-estimate-summary','search-batch-estimate-list',
|
||||
'cancel-search-batch-estimates','confirm-search-batch-estimates','search-release-review',
|
||||
'search-release-summary','search-release-milestone','search-release-due-date','search-release-list',
|
||||
'cancel-search-release','confirm-search-release'
|
||||
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',
|
||||
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
|
||||
const estimateInputs=[
|
||||
{{dataset:{{searchBatchEstimate:'stackchain/dashboard#811'}},value:'25'}},
|
||||
{{dataset:{{searchBatchEstimate:'stackchain/api#12'}},value:'30'}},
|
||||
];
|
||||
const document={{
|
||||
querySelector:selector=>elements[selector] || null,
|
||||
querySelectorAll:selector=>selector==='[data-search-batch-estimate]' ? estimateInputs : [],
|
||||
}};
|
||||
const calls=[];
|
||||
const processorOptions=[];
|
||||
const processor={{
|
||||
run:(items,estimates)=>{{calls.push({{type:'run',items,estimates}});return Promise.resolve();}},
|
||||
resume:()=>Promise.resolve(),pending:()=>0,
|
||||
}};
|
||||
const batchFactory=options=>{{processorOptions.push(options);return processor;}};
|
||||
const planning={{capacity_minutes:90,estimates:{{'stackchain/existing#1':20}}}};
|
||||
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,
|
||||
replacePlanning:value=>Object.assign(planning,value)}};
|
||||
const todaySync={{enqueueConfiguration:()=>{{}},flush:()=>{{}}}};
|
||||
const items=[
|
||||
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:811,title:'Capacity'}},
|
||||
{{kind:'issue',state:'open',repository:'stackchain/api',number:12,title:'API'}},
|
||||
];
|
||||
let selected=items;
|
||||
const mounted=createSearchBatchPlan.mount(
|
||||
document,batchFactory,todayWork,()=> 'timmy',()=>Promise.resolve(),()=>'',()=>Promise.resolve('queued'),
|
||||
todaySync,item=>item,index=>selected[index],()=>{{}},value=>value,value=>value
|
||||
);
|
||||
mounted.plan.start();
|
||||
items.forEach(item=>mounted.plan.toggle(item));
|
||||
listeners['queue-selected-search-results:click']();
|
||||
const before={{
|
||||
calls:calls.slice(),
|
||||
reviewHidden:elements['#search-batch-estimate-review'].hidden,
|
||||
actionsHidden:elements['#search-batch-actions'].hidden,
|
||||
markup:elements['#search-batch-estimate-list'].innerHTML,
|
||||
budget:processorOptions[0].timeBudget(),
|
||||
}};
|
||||
listeners['confirm-search-batch-estimates:click']();
|
||||
processorOptions[0].persistEstimate(items[0],25);
|
||||
processorOptions[0].onProgress({{status:'complete',queued:['stackchain/dashboard#811','stackchain/api#12'],failed:[]}});
|
||||
const afterCompleteHidden=elements['#search-batch-estimate-review'].hidden;
|
||||
process.stdout.write(JSON.stringify({{before,calls,afterCompleteHidden}}));
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["before"]["calls"] == []
|
||||
assert result["before"]["reviewHidden"] is False
|
||||
assert result["before"]["actionsHidden"] is True
|
||||
assert "Capacity" in result["before"]["markup"]
|
||||
assert "data-search-batch-estimate" in result["before"]["markup"]
|
||||
assert result["before"]["budget"] == {"capacity_minutes": 90, "planned_minutes": 20}
|
||||
assert result["calls"] == [{
|
||||
"type": "run",
|
||||
"items": [
|
||||
{"kind": "issue", "state": "open", "repository": "stackchain/dashboard", "number": 811, "title": "Capacity"},
|
||||
{"kind": "issue", "state": "open", "repository": "stackchain/api", "number": 12, "title": "API"},
|
||||
],
|
||||
"estimates": {"stackchain/dashboard#811": 25, "stackchain/api#12": 30},
|
||||
}]
|
||||
assert result["afterCompleteHidden"] is True
|
||||
|
||||
|
||||
def test_search_batch_reviews_and_applies_one_repository_release_plan_before_assignment():
|
||||
script = f"""
|
||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||
const listeners={{}};
|
||||
const elements={{}};
|
||||
for (const id of [
|
||||
'search-batch-actions','select-search-results','queue-selected-search-results',
|
||||
'defer-selected-search-results','plan-selected-search-results','search-selection-status',
|
||||
'cmd-search-action-status','resume-search-batch','open-palette','cmd-results',
|
||||
'cancel-search-selection','search-batch-estimate-review','search-batch-estimate-summary',
|
||||
'search-batch-estimate-list','cancel-search-batch-estimates','confirm-search-batch-estimates',
|
||||
'search-release-review','search-release-summary','search-release-milestone',
|
||||
'search-release-due-date','search-release-list','cancel-search-release','confirm-search-release'
|
||||
]) elements['#'+id]={{hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',
|
||||
addEventListener:(name,fn)=>listeners[id+':'+name]=fn,focus:()=>{{}}}};
|
||||
const document={{querySelector:selector=>elements[selector] || null,querySelectorAll:()=>[]}};
|
||||
const calls=[];
|
||||
const fetchJson=(url,options={{}})=>{{
|
||||
calls.push({{url,method:options.method || 'GET',body:options.body ? JSON.parse(options.body) : null}});
|
||||
if (url.endsWith('/milestones')) return Promise.resolve([{{id:9,title:'August RC'}},{{id:7,title:'Old',state:'closed'}}]);
|
||||
const number=Number(url.split('/issues/')[1].split('/')[0]);
|
||||
if (url.endsWith('/release-plan')) return Promise.resolve({{
|
||||
number,milestone:{{id:9,title:'August RC'}},due_date:'2026-08-31T23:59:59Z'
|
||||
}});
|
||||
return Promise.resolve();
|
||||
}};
|
||||
const processorOptions=[];
|
||||
const processors=[];
|
||||
const batchFactory=options=>{{processorOptions.push(options);const processor={{
|
||||
run:(items,estimates,context)=>{{calls.push({{run:items.map(item=>item.number),context}});return Promise.resolve();}},
|
||||
resume:()=>Promise.resolve(),pending:()=>0,
|
||||
}};processors.push(processor);return processor;}};
|
||||
const planning={{capacity_minutes:null,estimates:{{}}}};
|
||||
const todayWork={{limit:8,read:()=>[],planning:()=>planning,identity:item=>item.repository+'#'+item.number,replacePlanning:()=>{{}}}};
|
||||
const items=[
|
||||
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:815,title:'Release plan'}},
|
||||
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:816,title:'Second issue'}},
|
||||
];
|
||||
const mounted=createSearchBatchPlan.mount(
|
||||
document,batchFactory,todayWork,()=> 'timmy',fetchJson,()=>'',()=>Promise.resolve('queued'),
|
||||
{{enqueueConfiguration:()=>{{}},flush:()=>{{}}}},item=>item,index=>items[index],()=>{{}},
|
||||
value=>value,value=>value,null,null
|
||||
);
|
||||
mounted.plan.start();items.forEach(item=>mounted.plan.toggle(item));
|
||||
listeners['plan-selected-search-results:click']().then(async()=>{{
|
||||
const before={{calls:calls.slice(),reviewHidden:elements['#search-release-review'].hidden,
|
||||
actionsHidden:elements['#search-batch-actions'].hidden,options:elements['#search-release-milestone'].innerHTML,
|
||||
list:elements['#search-release-list'].innerHTML}};
|
||||
elements['#search-release-milestone'].value='9';
|
||||
elements['#search-release-milestone'].selectedIndex=0;
|
||||
elements['#search-release-milestone'].options=[{{textContent:'August RC'}}];
|
||||
elements['#search-release-due-date'].value='2026-08-31';
|
||||
listeners['confirm-search-release:click']();
|
||||
const queued=await processorOptions[2].queue({{...items[0],assigned_to_me:true}},
|
||||
{{milestone_id:9,milestone_title:'August RC',due_date:'2026-08-31T23:59:59Z'}});
|
||||
process.stdout.write(JSON.stringify({{before,calls,queued,journals:processorOptions.map(option=>option.journalName)}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
result = run_node(script)
|
||||
assert result["before"]["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/milestones", "method": "GET", "body": None,
|
||||
}]
|
||||
assert result["before"]["reviewHidden"] is False
|
||||
assert result["before"]["actionsHidden"] is True
|
||||
assert "August RC" in result["before"]["options"]
|
||||
assert "Old" not in result["before"]["options"]
|
||||
assert "Release plan" in result["before"]["list"]
|
||||
assert result["calls"][1] == {
|
||||
"run": [815, 816],
|
||||
"context": {
|
||||
"milestone_id": 9, "milestone_title": "August RC",
|
||||
"due_date": "2026-08-31T23:59:59Z",
|
||||
},
|
||||
}
|
||||
assert result["calls"][2:] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/815/release-plan",
|
||||
"method": "PATCH",
|
||||
"body": {"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z"},
|
||||
}]
|
||||
assert result["queued"] == "queued"
|
||||
assert result["journals"] == [
|
||||
"search-today-batch", "search-later-batch", "search-release-batch",
|
||||
]
|
||||
@@ -120,11 +120,11 @@ async function dispatchMessage(data, ports = []) {{
|
||||
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null) {{
|
||||
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null, url = null) {{
|
||||
let pending;
|
||||
listeners.notificationclick({{
|
||||
action,
|
||||
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId}}, close: () => {{ state.notificationClosed = true; }}}},
|
||||
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId, ...(url ? {{url}} : {{}})}}, close: () => {{ state.notificationClosed = true; }}}},
|
||||
waitUntil: promise => {{ pending = promise; }},
|
||||
}});
|
||||
if (pending) await pending;
|
||||
@@ -150,7 +150,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -159,14 +159,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -174,7 +174,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -182,14 +182,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -198,21 +198,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ def test_image_share_target_stages_one_supported_image_and_redirects_to_capture(
|
||||
}
|
||||
|
||||
|
||||
def test_image_share_target_rejects_multiple_images_without_staging_private_data():
|
||||
def test_image_share_target_stages_ordered_evidence_bundle():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const form = new FormData();
|
||||
@@ -256,16 +256,19 @@ def test_image_share_target_rejects_multiple_images_without_staging_private_data
|
||||
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
|
||||
Object.defineProperty(request, 'mode', {value:'navigate'});
|
||||
const response = await dispatch('fetch', request);
|
||||
const record=state.sharedRecords['shared-image'];
|
||||
process.stdout.write(JSON.stringify({
|
||||
status:response.status, location:response.headers.get('Location'), records:Object.keys(state.sharedRecords),
|
||||
status:response.status, location:response.headers.get('Location'),
|
||||
names:record.attachments.map(value=>value.filename),sizes:record.attachments.map(value=>value.blob.size),
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": 303,
|
||||
"location": "/dashboard/?launch=new&shared=multiple",
|
||||
"records": [],
|
||||
"location": "/dashboard/?launch=new&shared=images",
|
||||
"names": ["one.png", "two.jpg"],
|
||||
"sizes": [3, 3],
|
||||
}
|
||||
|
||||
|
||||
@@ -457,6 +460,36 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
|
||||
assert result["notificationClosed"] is True
|
||||
|
||||
|
||||
def test_created_issue_receipt_tap_opens_the_confirmed_canonical_gitea_url():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const issueUrl = 'https://forge.example/git/stackchain/api/issues/44';
|
||||
state.receiptLogin = 'timmy';
|
||||
state.flushResult = {login:'timmy', receipts:[
|
||||
{id:'capture-1',status:'confirmed',kind:'issue',url:issueUrl},
|
||||
]};
|
||||
state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}];
|
||||
await dispatchSync('stackchain-issue-outbox-v1');
|
||||
await dispatchNotificationClick('', '', null, 'stackchain-delivery-capture-1', issueUrl);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == [{
|
||||
"title": "Queued issue created",
|
||||
"options": {
|
||||
"body": "Tap to open it in Stackchain.",
|
||||
"tag": "stackchain-delivery-capture-1",
|
||||
"data": {"url": "https://forge.example/git/stackchain/api/issues/44"},
|
||||
},
|
||||
}]
|
||||
assert result["focused"] == [
|
||||
"https://forge.example/git/stackchain/api/issues/44"
|
||||
]
|
||||
assert result["opened"] == []
|
||||
assert result["notificationClosed"] is True
|
||||
|
||||
|
||||
def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -761,7 +794,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -784,14 +817,17 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/session.js",
|
||||
"/dashboard/static/feature-loader.js",
|
||||
"/dashboard/static/conversation-action-hydrator.js",
|
||||
"/dashboard/static/security-center.js",
|
||||
"/dashboard/static/markdown.js",
|
||||
"/dashboard/static/commands.js",
|
||||
"/dashboard/static/saved-searches.js",
|
||||
"/dashboard/static/search-preview.js",
|
||||
"/dashboard/static/search-defer.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/unfiled-captures.js",
|
||||
"/dashboard/static/unfiled-draft-sync.js",
|
||||
"/dashboard/static/shared-image-capture.js",
|
||||
"/dashboard/static/draft-filing-session.js",
|
||||
"/dashboard/static/draft-capacity-dialog.js",
|
||||
@@ -833,7 +869,10 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/search-batch-plan.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/comment-actions.js",
|
||||
"/dashboard/static/issue-evidence-review.js",
|
||||
"/dashboard/static/issue-evidence-editor.js",
|
||||
"/dashboard/static/issue-attachment.js",
|
||||
"/dashboard/static/issue-filing-review.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/create-and-start.js",
|
||||
@@ -864,6 +903,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/mobile-composer-viewport.js",
|
||||
"/dashboard/static/mention-composer.js",
|
||||
"/dashboard/static/push-notifications.js",
|
||||
"/dashboard/static/issue-filing-receipt.js",
|
||||
"/dashboard/static/background-issue-sync.js",
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,23 @@ shared.consume({{
|
||||
}
|
||||
|
||||
|
||||
def test_shared_evidence_bundle_is_consumed_once_in_order():
|
||||
script = f"""
|
||||
const shared=require({json.dumps(str(MODULE))});const calls=[];
|
||||
const store={{get:async()=>({{attachments:[
|
||||
{{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}},
|
||||
{{filename:'two.jpg',contentType:'image/jpeg',blob:new Blob(['two'],{{type:'image/jpeg'}})}},
|
||||
]}}),delete:async id=>calls.push(['delete',id])}};
|
||||
(async()=>{{const result=await shared.consume({{marker:'images',store,restore:value=>calls.push(['restore',value.map(x=>x.filename)]),status:value=>calls.push(['status',value])}});process.stdout.write(JSON.stringify({{result,calls}}));}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
|
||||
assert output == {"result": True, "calls": [
|
||||
["restore", ["one.png", "two.jpg"]], ["delete", "shared-image"],
|
||||
["status", "2 shared screenshots ready to file with this issue."],
|
||||
]}
|
||||
|
||||
|
||||
|
||||
def test_invalid_shared_image_reports_error_without_touching_open_capture():
|
||||
script = f"""
|
||||
const shared = require({json.dumps(str(MODULE))});
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v101';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v102';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,100 @@ process.stdout.write(JSON.stringify({{opened,skipped,afterFiled,previous}}));
|
||||
}
|
||||
|
||||
|
||||
def test_draft_filing_session_restores_all_saved_evidence():
|
||||
script = f"""
|
||||
const createDraftFilingSession=require({json.dumps(str(DRAFT_SESSION))});
|
||||
const evidence=[{{filename:'one.png'}},{{filename:'two.png'}}];
|
||||
const restored=[];
|
||||
const elements=new Map();
|
||||
const qs=selector=>{{
|
||||
if(!elements.has(selector)) elements.set(selector,{{hidden:false,disabled:false,textContent:'',addEventListener:()=>{{}}}});
|
||||
return elements.get(selector);
|
||||
}};
|
||||
const session=createDraftFilingSession({{list:()=>[{{id:'planned'}}]}});
|
||||
session.attach(qs,{{
|
||||
captures:{{resume:async()=>({{repository:'o/r',title:'Planned',body:'',labelIds:[],attachments:evidence}})}},
|
||||
issueCapture:{{saveDraft:()=>{{}}}},attachment:{{restore:value=>restored.push(value),clear:()=>restored.push(null)}},
|
||||
getLogin:()=>'timmy',setResumedId:()=>{{}},openSheet:async()=>{{}},setFilingMode:()=>{{}},
|
||||
}});
|
||||
session.start('planned');
|
||||
(async()=>{{await session.nextCapture();process.stdout.write(JSON.stringify(restored));}})();
|
||||
"""
|
||||
assert run_node(script) == [[{"filename": "one.png"}, {"filename": "two.png"}]]
|
||||
|
||||
|
||||
def test_ready_draft_summary_exposes_truthful_mobile_action_and_plan_details():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
const planned=createUnfiledCaptures.summary({{
|
||||
filingPlan:{{repository:'stackchain/dashboard',completionIntent:'create-and-start',estimateMinutes:45}},
|
||||
attachmentCount:2,
|
||||
}});
|
||||
const minimal=createUnfiledCaptures.summary({{}});
|
||||
process.stdout.write(JSON.stringify({{planned,minimal}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"planned": {
|
||||
"label": "Ready to file",
|
||||
"repository": "stackchain/dashboard",
|
||||
"ready": True,
|
||||
"action": "Review & start",
|
||||
"details": "Create & start · 45 min · 2 screenshots",
|
||||
},
|
||||
"minimal": {
|
||||
"label": "Needs filing",
|
||||
"repository": "",
|
||||
"ready": False,
|
||||
"action": "Choose repository",
|
||||
"details": "Create issue · No screenshots",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_ready_draft_opens_final_review_with_restored_intent_and_evidence():
|
||||
script = f"""
|
||||
const createDraftFilingSession=require({json.dumps(str(DRAFT_SESSION))});
|
||||
const events=[];
|
||||
const trigger={{id:'ready-action'}};
|
||||
const resumed={{repository:'o/r',title:'Planned',body:'Evidence',labelIds:[7],
|
||||
completionIntent:'create-and-start',estimateMinutes:30,
|
||||
attachments:[{{filename:'one.png'}},{{filename:'two.png'}}]}};
|
||||
const elements=new Map();
|
||||
const qs=selector=>{{
|
||||
if(!elements.has(selector)) elements.set(selector,{{id:selector.slice(1),hidden:false,disabled:false,textContent:'',addEventListener:()=>{{}},
|
||||
requestSubmit:button=>events.push(['review',resumed,button.id])}});
|
||||
return elements.get(selector);
|
||||
}};
|
||||
const session=createDraftFilingSession({{list:()=>[{{id:'planned'}}]}});
|
||||
session.attach(qs,{{
|
||||
captures:{{resume:async()=>resumed}},
|
||||
capture:()=>({{saveDraft:value=>events.push(['save',value])}}),
|
||||
attachment:{{restore:value=>events.push(['evidence',value]),clear:()=>{{}}}},
|
||||
getLogin:()=>'timmy',setResumedId:id=>events.push(['source',id]),
|
||||
openSheet:async()=>events.push(['sheet']),setFilingMode:()=>events.push(['filing']),
|
||||
review:async()=>{{events.push(['metadata']);events.push(['duplicates']);}},
|
||||
}});
|
||||
session.start('planned');
|
||||
(async()=>{{await session.reviewCapture('planned',trigger);process.stdout.write(JSON.stringify({{
|
||||
events,origin:session.takeTrigger().id
|
||||
}}));}})();
|
||||
"""
|
||||
|
||||
output = run_node(script)
|
||||
events = output["events"]
|
||||
assert [event[0] for event in events] == [
|
||||
"save", "source", "sheet", "evidence", "filing", "metadata",
|
||||
"duplicates", "review"
|
||||
]
|
||||
assert events[-1][1]["labelIds"] == [7]
|
||||
assert events[-1][1]["attachments"] == [
|
||||
{"filename": "one.png"}, {"filename": "two.png"}
|
||||
]
|
||||
assert events[-1][2] == "create-and-start-issue"
|
||||
assert output["origin"] == "ready-action"
|
||||
|
||||
|
||||
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
@@ -85,6 +179,218 @@ process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement
|
||||
assert output["restored"][0]["ownerLogin"] == "timmy"
|
||||
|
||||
|
||||
def test_unfiled_capture_restores_ordered_evidence_bundle():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
const values=new Map(),blobs=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore={{put:async(id,value)=>blobs.set(id,value),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}};
|
||||
const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}}),note:'Note '+name}});
|
||||
(async()=>{{const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'bundle'}});
|
||||
const saved=await captures.save({{title:'Journey',body:'Steps',attachments:['one','two','three'].map(image)}});
|
||||
const resumed=await captures.resume(saved.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.attachments.map(x=>x.filename),notes:resumed.attachments.map(x=>x.note),stored:blobs.get('bundle').attachments.map(x=>x.note)}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["listed"]["attachmentCount"] == 3
|
||||
assert output["names"] == ["one", "two", "three"]
|
||||
assert output["notes"] == ["Note one", "Note two", "Note three"]
|
||||
assert output["stored"] == output["notes"]
|
||||
|
||||
|
||||
def test_unfiled_captures_export_and_import_ordered_evidence_between_devices():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
function device() {{
|
||||
const values=new Map(), blobs=new Map();
|
||||
return {{
|
||||
captures:createUnfiledCaptures({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}},
|
||||
attachmentStore:{{put:async(id,v)=>blobs.set(id,v),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}},
|
||||
getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'phone-draft',now:()=>42,
|
||||
}}), blobs,
|
||||
}};
|
||||
}}
|
||||
(async()=>{{
|
||||
const phone=device(), desktop=device();
|
||||
const image=(name,note)=>({{filename:name,contentType:'image/png',note,blob:new Blob([name],{{type:'image/png'}})}});
|
||||
await phone.captures.save({{title:'Field failure',body:'Steps',attachments:[image('one.png','First'),image('two.png','Second')],blockers:[{{repository:'o/api',number:7,title:'API'}}]}});
|
||||
const exported=await phone.captures.exportOwned('timmy');
|
||||
const imported=await desktop.captures.mergeRemote(exported,'timmy');
|
||||
const resumed=await desktop.captures.resume('phone-draft','timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
exported:exported[0], imported,
|
||||
names:resumed.attachments.map(item=>item.filename),
|
||||
notes:resumed.attachments.map(item=>item.note),
|
||||
bytes:await Promise.all(resumed.attachments.map(async item=>Buffer.from(await item.blob.arrayBuffer()).toString())),
|
||||
blockers:resumed.blockers,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["exported"]["id"] == "phone-draft"
|
||||
assert output["exported"]["saved_at"] == 42
|
||||
assert [item["filename"] for item in output["exported"]["evidence"]] == ["one.png", "two.png"]
|
||||
assert output["imported"] == 1
|
||||
assert output["names"] == ["one.png", "two.png"]
|
||||
assert output["notes"] == ["First", "Second"]
|
||||
assert output["bytes"] == ["one.png", "two.png"]
|
||||
assert output["blockers"] == [{"repository": "o/api", "number": 7, "title": "API"}]
|
||||
|
||||
|
||||
def test_unfiled_captures_resume_complete_filing_plan_on_another_device():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
function device(id) {{
|
||||
const values=new Map();
|
||||
return createUnfiledCaptures({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||
getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>id,now:()=>42,
|
||||
}});
|
||||
}}
|
||||
(async()=>{{
|
||||
const phone=device('planned-draft'), desktop=device('unused');
|
||||
await phone.save({{
|
||||
repository:'stackchain/dashboard',title:'Restore my plan',body:'Do not make me plan twice',
|
||||
labelIds:[7,3,7],milestoneId:12,dueDate:'2026-08-21',
|
||||
templateName:'Bug report',templateId:'bug.yml',capturedBody:'Original field notes',
|
||||
assignee:'alexander',assigneeName:'Alexander',unassigned:false,
|
||||
estimateMinutes:45,completionIntent:'create-and-start',
|
||||
blockers:[{{repository:'stackchain/api',number:9,title:'API rollout'}}],
|
||||
}});
|
||||
const exported=await phone.exportOwned('timmy');
|
||||
await desktop.mergeRemote(exported,'timmy');
|
||||
const resumed=await desktop.resume('planned-draft','timmy');
|
||||
process.stdout.write(JSON.stringify({{exported:exported[0],resumed}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["exported"]["filing_plan"] == {
|
||||
"repository": "stackchain/dashboard",
|
||||
"label_ids": [7, 3],
|
||||
"milestone_id": 12,
|
||||
"due_date": "2026-08-21",
|
||||
"template_name": "Bug report",
|
||||
"template_id": "bug.yml",
|
||||
"captured_body": "Original field notes",
|
||||
"assignee": "alexander",
|
||||
"assignee_name": "Alexander",
|
||||
"estimate_minutes": 45,
|
||||
"completion_intent": "create-and-start",
|
||||
}
|
||||
assert output["resumed"] == {
|
||||
"repository": "stackchain/dashboard",
|
||||
"title": "Restore my plan",
|
||||
"body": "Do not make me plan twice",
|
||||
"labelIds": [7, 3],
|
||||
"milestoneId": 12,
|
||||
"dueDate": "2026-08-21",
|
||||
"templateName": "Bug report",
|
||||
"templateId": "bug.yml",
|
||||
"capturedBody": "Original field notes",
|
||||
"assignee": "alexander",
|
||||
"assigneeName": "Alexander",
|
||||
"estimateMinutes": 45,
|
||||
"completionIntent": "create-and-start",
|
||||
"blockers": [{"repository": "stackchain/api", "number": 9, "title": "API rollout"}],
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_captures_reconcile_remote_replaces_stale_content_and_evidence():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
const values=new Map(),blobs=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore={{get:id=>blobs.get(id),put:(id,value)=>blobs.set(id,value),delete:id=>blobs.delete(id)}};
|
||||
const captures=createUnfiledCaptures({{
|
||||
storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'field',now:()=>1,
|
||||
}});
|
||||
(async()=>{{
|
||||
await captures.save({{title:'Field failure',body:'phone edit',attachment:{{
|
||||
filename:'phone.png',contentType:'image/png',blob:new Blob(['phone'])
|
||||
}}}});
|
||||
await captures.reconcileRemote([{{
|
||||
id:'field',title:'Field failure',body:'desktop edit',saved_at:2,evidence:[{{
|
||||
filename:'desktop.png',content_type:'image/png',note:'newer',data:'ZGVza3RvcA=='
|
||||
}}]
|
||||
}}], 'timmy');
|
||||
const resumed=await captures.resume('field','timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
body:resumed.body,filename:resumed.attachments[0].filename,note:resumed.attachments[0].note,
|
||||
bytes:await resumed.attachments[0].blob.text(),count:captures.list().length,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"body": "desktop edit",
|
||||
"filename": "desktop.png",
|
||||
"note": "newer",
|
||||
"bytes": "desktop",
|
||||
"count": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_captures_reconcile_rolls_back_evidence_when_metadata_write_fails():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
const values=new Map(),blobs=new Map();let failWrite=false;
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>{{if(failWrite)throw new Error('quota');values.set(k,v)}}}};
|
||||
const attachmentStore={{get:id=>blobs.get(id),put:(id,value)=>blobs.set(id,value),delete:id=>blobs.delete(id)}};
|
||||
const captures=createUnfiledCaptures({{
|
||||
storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'field',now:()=>1,
|
||||
}});
|
||||
(async()=>{{
|
||||
await captures.save({{title:'Field failure',body:'phone edit',attachment:{{
|
||||
filename:'phone.png',contentType:'image/png',blob:new Blob(['phone'])
|
||||
}}}});
|
||||
failWrite=true;let error='';
|
||||
try {{
|
||||
await captures.reconcileRemote([{{
|
||||
id:'field',title:'Field failure',body:'desktop edit',saved_at:2,evidence:[{{
|
||||
filename:'desktop.png',content_type:'image/png',data:'ZGVza3RvcA=='
|
||||
}}]
|
||||
}}], 'timmy');
|
||||
}} catch (caught) {{ error=caught.message; }}
|
||||
const resumed=await captures.resume('field','timmy');
|
||||
const proof=resumed.attachments?.[0] || resumed.attachment;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
error,body:resumed.body,filename:proof.filename,
|
||||
bytes:await proof.blob.text(),
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"error": "quota",
|
||||
"body": "phone edit",
|
||||
"filename": "phone.png",
|
||||
"bytes": "phone",
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_capture_restores_selected_blockers():
|
||||
script = f"""
|
||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const captures=createUnfiledCaptures({{storage,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'blocked'}});
|
||||
const blockers=[{{repository:'o/api',number:7,title:'API ready'}},{{repository:'o/web',number:8,title:'Web ready'}}];
|
||||
const saved=captures.save({{title:'Blocked work',body:'Context',blockers}});
|
||||
const resumed=captures.resume(saved.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{listed:captures.list()[0],resumed}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["listed"]["blockerCount"] == 2
|
||||
assert output["resumed"]["blockers"] == [
|
||||
{"repository": "o/api", "number": 7, "title": "API ready"},
|
||||
{"repository": "o/web", "number": 8, "title": "Web ready"},
|
||||
]
|
||||
|
||||
|
||||
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
@@ -357,15 +663,15 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
|
||||
assert "data-capture-id=\"' + escapeHtml(item.capture_id) + '\"" in html
|
||||
assert "requestAnimationFrame(() =>" in html
|
||||
assert "savedCard?.scrollIntoView({block:'nearest'})" in html
|
||||
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in html
|
||||
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in UNFILED.read_text()
|
||||
assert "await dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text()
|
||||
assert "await createIssueAttachmentController.serialize()" in html
|
||||
assert "createUnfiledAttachmentStore()" in html
|
||||
assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text()
|
||||
assert "dependencies.attachment[evidence ? 'restore' : 'clear'](evidence)" in DRAFT_SESSION.read_text()
|
||||
assert "await unfiledCaptures.completeResume(rUC)" in html
|
||||
assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html
|
||||
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
|
||||
assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
|
||||
assert "issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
|
||||
assert "item.kind === 'unfiled-issue'" in html
|
||||
assert '.create-issue-actions button { min-height:44px;' in html
|
||||
assert '.draft-card:focus-visible' in html
|
||||
@@ -373,6 +679,39 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
|
||||
assert '@media(max-width:320px)' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_planned_draft_can_be_saved_and_resumed_without_replanning():
|
||||
html = await dashboard()
|
||||
feature = UNFILED.read_text()
|
||||
|
||||
assert "const captureDraft = currentIssueCaptureDraft();" in html
|
||||
assert "return withFilingEstimate(issueTemplatePicker.fields(" in html
|
||||
assert "draft.estimateMinutes = Number(value) || null" in feature
|
||||
assert "qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';" in html
|
||||
assert "qs('#file-new-issue').hidden = enabled" in feature
|
||||
assert "qs('.create-issue-capture-actions').hidden = false" in feature
|
||||
assert "label:ready ? 'Ready to file' : 'Needs filing'" in feature
|
||||
assert "repository:plan.repository || ''" in feature
|
||||
assert "Saved planned Draft" in feature
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_ready_draft_card_opens_validated_final_review_in_one_tap():
|
||||
html = await dashboard()
|
||||
|
||||
assert "isUnfiled ? item.action" in html
|
||||
assert "[item.label, item.repository, item.details].filter(Boolean).join(' · ')" in html
|
||||
assert "escapeHtml([item.label" in html
|
||||
assert "await dFS.open(item.capture_id, item.ready ? button : null)" in html
|
||||
assert "capture:()=>issueCapture" in html
|
||||
assert "review:r=>loadIssueFilingMetadata(r.repository,r)" in html
|
||||
assert ".then(()=>issueCapture.findDuplicates(currentIssueCaptureDraft())).then(renderIssueDuplicates)" in html
|
||||
assert "qs('#create-issue-form').requestSubmit(submitter)" in DRAFT_SESSION.read_text()
|
||||
assert "dFS.takeTrigger() || event.submitter || qs('#submit-new-issue')" in html
|
||||
assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px; width:100%; }' in html
|
||||
assert '@media(max-width:320px)' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
|
||||
html = await dashboard()
|
||||
@@ -382,13 +721,12 @@ async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
|
||||
assert 'id="draft-filing-progress"' in html
|
||||
assert 'id="previous-draft"' in html
|
||||
assert 'id="skip-draft"' in html
|
||||
assert "dFS.start(item.capture_id)" in html
|
||||
assert "await dFS.nextCapture()" in html
|
||||
assert "await dFS.open(item.capture_id, item.ready ? button : null)" in html
|
||||
assert "await dFS.advance(fS.id)" in html
|
||||
feature = DRAFT_SESSION.read_text()
|
||||
assert "Draft ${state.position} of ${state.total}" in feature
|
||||
assert "qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft'" in feature
|
||||
assert '.draft-filing-session-actions button { min-height:44px;' in html
|
||||
assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px;' in html
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
|
||||
|
||||
|
||||
@@ -401,8 +739,11 @@ async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision(
|
||||
assert 'id="review-full-drafts"' in html
|
||||
assert 'id="replace-oldest-draft"' in html
|
||||
assert 'id="keep-editing-draft"' in html
|
||||
assert "showDraftCapacityDialog(unfiledCaptures)" in html
|
||||
assert "showDraftCapacityDialog(unfiledCaptures, qs)" in html
|
||||
assert "bindDraftCapacityDialog({" in html
|
||||
feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text()
|
||||
assert "const draft = currentIssueCaptureDraft();" in feature
|
||||
assert "Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}" in feature
|
||||
assert "unfiledCaptures.replaceOldest(draft, oldest.id)" in feature
|
||||
assert '.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh;' in html
|
||||
assert '.draft-capacity-actions button { min-height:44px;' in html
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.frontend_bundle import FEATURE_SOURCES, build_frontend
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
|
||||
|
||||
def test_cross_device_unfiled_draft_sync_is_shipped_and_wired_to_user_flow():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
assert '<script src="static/unfiled-draft-sync.js"></script>' in html
|
||||
assert "static/unfiled-draft-sync.js" in FEATURE_SOURCES["today-timer"]
|
||||
assert "createUnfiledDraftSync(" in dashboard
|
||||
assert "unfiledCaptures, fetchReviewJson" in dashboard
|
||||
assert "Saved on this device · sync pending" in (ROOT / "frontend" / "unfiled-draft-sync.js").read_text()
|
||||
|
||||
built = build_frontend(ROOT / "frontend")
|
||||
assert "unfiled-draft-sync.js" not in built.dashboard_html
|
||||
assert b"stackchain.unfiled-draft-sync.v1" in built.feature_bundles["today-timer"].runtime_bytes
|
||||
@@ -0,0 +1,132 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
|
||||
|
||||
|
||||
def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None):
|
||||
return {
|
||||
"id": draft_id,
|
||||
"title": title,
|
||||
"body": "Steps from the field",
|
||||
"saved_at": 1_723_600_000_000,
|
||||
"blockers": [{"repository": "stackchain/api", "number": 7, "title": "API rollout"}],
|
||||
"evidence": (evidence if evidence is not None else [
|
||||
{
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"note": "Error after tapping Pay",
|
||||
"data": "cG5nLWJ5dGVz",
|
||||
}
|
||||
]),
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_drafts_are_revisioned_ordered_and_account_scoped(tmp_path):
|
||||
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
||||
|
||||
created = store.replace(" Timmy ", 0, [draft(), draft("second", title="Second")])
|
||||
|
||||
assert created == {"revision": 1, "drafts": [draft(), draft("second", title="Second")]}
|
||||
assert store.get("timmy") == created
|
||||
assert store.get("alexander") == {"revision": 0, "drafts": []}
|
||||
with pytest.raises(UnfiledDraftConflict) as conflict:
|
||||
store.replace("timmy", 0, [draft(title="Stale overwrite")])
|
||||
assert conflict.value.snapshot == created
|
||||
|
||||
|
||||
def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path):
|
||||
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3", limit=2, max_total_bytes=12)
|
||||
|
||||
with pytest.raises(ValueError, match="limited to 2"):
|
||||
store.replace("timmy", 0, [draft("one", evidence=[]), draft("two", evidence=[]), draft("three", evidence=[])])
|
||||
with pytest.raises(ValueError, match="evidence is too large"):
|
||||
store.replace("timmy", 0, [draft(evidence=[{
|
||||
"filename": "large.png", "content_type": "image/png", "data": "eHh4eHh4eHh4eHh4eHh4eHh4eHg=",
|
||||
}])])
|
||||
with pytest.raises(ValueError, match="valid base64"):
|
||||
store.replace("timmy", 0, [draft(evidence=[{
|
||||
"filename": "bad.png", "content_type": "image/png", "data": "not base64!",
|
||||
}])])
|
||||
|
||||
|
||||
def test_unfiled_drafts_validate_and_round_trip_complete_filing_plan(tmp_path):
|
||||
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
||||
planned = draft(evidence=[])
|
||||
planned.pop("evidence")
|
||||
planned["filing_plan"] = {
|
||||
"repository": "stackchain/dashboard",
|
||||
"label_ids": [7, 3],
|
||||
"milestone_id": 12,
|
||||
"due_date": "2026-08-21",
|
||||
"template_name": "Bug report",
|
||||
"template_id": "bug.yml",
|
||||
"captured_body": "Original field notes",
|
||||
"assignee": "alexander",
|
||||
"assignee_name": "Alexander",
|
||||
"estimate_minutes": 45,
|
||||
"completion_intent": "create-and-start",
|
||||
}
|
||||
|
||||
assert store.replace("timmy", 0, [planned])["drafts"] == [planned]
|
||||
|
||||
invalid = draft(evidence=[])
|
||||
invalid["filing_plan"] = {**planned["filing_plan"], "repository": "not-a-repository"}
|
||||
with pytest.raises(ValueError, match="filing repository is invalid"):
|
||||
store.replace("timmy", 1, [invalid])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unfiled_draft_api_is_account_scoped_csrf_protected_and_conflict_safe(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_DB", str(tmp_path / "drafts.sqlite3"))
|
||||
identity = {"login": "Timmy"}
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": identity["login"]}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
planned = draft(evidence=[])
|
||||
planned["filing_plan"] = {
|
||||
"repository": "stackchain/dashboard",
|
||||
"label_ids": [7, 3],
|
||||
"milestone_id": 12,
|
||||
"due_date": "2026-08-21",
|
||||
"template_name": "Bug report",
|
||||
"template_id": "bug.yml",
|
||||
"captured_body": "Original field notes",
|
||||
"assignee": "alexander",
|
||||
"assignee_name": "Alexander",
|
||||
"estimate_minutes": 45,
|
||||
"completion_intent": "create-and-start",
|
||||
}
|
||||
payload = {"revision": 0, "drafts": [planned]}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||
forbidden = await client.put("/api/v1/unfiled-drafts", json=payload)
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
saved = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers)
|
||||
stale = await client.put("/api/v1/unfiled-drafts", json=payload, headers=headers)
|
||||
fetched = await client.get("/api/v1/unfiled-drafts")
|
||||
identity["login"] = "Alexander"
|
||||
isolated = await client.get("/api/v1/unfiled-drafts")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert saved.status_code == 200
|
||||
expected = planned.copy()
|
||||
expected.pop("evidence")
|
||||
assert saved.json() == {"revision": 1, "drafts": [expected]}
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["detail"]["snapshot"] == saved.json()
|
||||
assert fetched.json() == saved.json()
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
assert isolated.json() == {"revision": 0, "drafts": []}
|
||||
@@ -0,0 +1,305 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SYNC = Path(__file__).parents[1] / "frontend" / "unfiled-draft-sync.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_unfiled_draft_sync_moves_capture_between_devices_and_propagates_delete():
|
||||
script = f"""
|
||||
const createSync=require({json.dumps(str(SYNC))});
|
||||
let remote={{revision:0,drafts:[]}};
|
||||
function api(url,init={{}}) {{
|
||||
if (!init.method) return Promise.resolve(JSON.parse(JSON.stringify(remote)));
|
||||
const body=JSON.parse(init.body);
|
||||
if (body.revision!==remote.revision) {{
|
||||
const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};return Promise.reject(error);
|
||||
}}
|
||||
remote={{revision:remote.revision+1,drafts:body.drafts}};
|
||||
return Promise.resolve(JSON.parse(JSON.stringify(remote)));
|
||||
}}
|
||||
function device(initial=[]) {{
|
||||
let drafts=initial.map(item=>({{...item}})); const values=new Map(); const states=[];
|
||||
const captures={{
|
||||
exportOwned:async()=>drafts.map(item=>({{...item}})),
|
||||
mergeRemote:async(items)=>{{let count=0;for(const item of items)if(!drafts.some(x=>x.id===item.id)){{drafts.push({{...item}});count++}}return count}},
|
||||
discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}},
|
||||
}};
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
const sync=createSync({{captures,fetchJson:api,storage,onState:state=>states.push(state)}});
|
||||
return {{sync,states,drafts:()=>drafts}};
|
||||
}}
|
||||
(async()=>{{
|
||||
const phone=device([{{id:'field',title:'Field failure',body:'Steps',saved_at:42,evidence:[{{filename:'proof.png',content_type:'image/png',data:'cHJvb2Y='}}]}}]);
|
||||
await phone.sync.sync('timmy');
|
||||
const desktop=device();
|
||||
await desktop.sync.sync('timmy');
|
||||
await desktop.sync.remove('field','timmy');
|
||||
await phone.sync.sync('timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
remote,desktop:desktop.drafts(),phone:phone.drafts(),
|
||||
phoneStates:phone.states.map(x=>x.status),desktopStates:desktop.states.map(x=>x.status),
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["remote"]["revision"] == 2
|
||||
assert output["remote"]["drafts"] == []
|
||||
assert output["desktop"] == []
|
||||
assert output["phone"] == []
|
||||
assert output["phoneStates"][-1] == "ready"
|
||||
assert output["desktopStates"][-1] == "ready"
|
||||
|
||||
|
||||
def test_unfiled_draft_sync_rebases_and_retries_a_revision_race_without_user_action():
|
||||
script = f"""
|
||||
const createSync=require({json.dumps(str(SYNC))});
|
||||
let calls=0, puts=0;const states=[];
|
||||
let drafts=[{{id:'local',title:'Local',body:'',saved_at:1}}];
|
||||
let remote={{revision:1,drafts:[]}};
|
||||
const sync=createSync({{
|
||||
captures:{{
|
||||
exportOwned:async()=>drafts,
|
||||
reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}},
|
||||
discard:async()=>true,
|
||||
}},
|
||||
storage:{{getItem:()=>null,setItem:()=>{{}}}},
|
||||
fetchJson:async(_url,init)=>{{
|
||||
calls++;
|
||||
if(!init)return JSON.parse(JSON.stringify(remote));
|
||||
puts++;
|
||||
const body=JSON.parse(init.body);
|
||||
if(puts===1){{
|
||||
remote={{revision:2,drafts:[{{id:'remote',title:'Remote',body:'',saved_at:2}}]}};
|
||||
const error=new Error('conflict');error.status=409;
|
||||
error.payload={{detail:{{snapshot:JSON.parse(JSON.stringify(remote))}}}};
|
||||
throw error;
|
||||
}}
|
||||
if(body.revision!==remote.revision)throw new Error('stale retry');
|
||||
remote={{revision:remote.revision+1,drafts:body.drafts}};
|
||||
return JSON.parse(JSON.stringify(remote));
|
||||
}},
|
||||
onState:state=>states.push(state),
|
||||
}});
|
||||
(async()=>{{
|
||||
const result=await sync.sync('timmy');
|
||||
process.stdout.write(JSON.stringify({{calls,puts,states,result,drafts,remote}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["result"] is True
|
||||
assert output["puts"] == 2
|
||||
assert output["calls"] == 4
|
||||
assert [item["id"] for item in output["remote"]["drafts"]] == ["remote", "local"]
|
||||
assert output["drafts"] == output["remote"]["drafts"]
|
||||
assert output["states"][-1]["status"] == "ready"
|
||||
|
||||
|
||||
def test_unfiled_draft_sync_bounds_conflicts_and_exposes_a_retry_path():
|
||||
script = f"""
|
||||
const createSync=require({json.dumps(str(SYNC))});
|
||||
let contended=true, puts=0;
|
||||
let drafts=[{{id:'local',title:'Local',body:'kept',saved_at:1}}];
|
||||
let remote={{revision:1,drafts:[]}};const states=[];
|
||||
const controller=createSync({{
|
||||
captures:{{
|
||||
exportOwned:async()=>JSON.parse(JSON.stringify(drafts)),
|
||||
reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}},
|
||||
discard:async()=>true,
|
||||
}},
|
||||
storage:{{getItem:()=>null,setItem:()=>{{}}}},getLogin:()=> 'timmy',maxConflictRetries:1,
|
||||
fetchJson:async(_url,init)=>{{
|
||||
if(!init)return JSON.parse(JSON.stringify(remote));
|
||||
puts++;const body=JSON.parse(init.body);
|
||||
if(contended){{
|
||||
remote={{revision:remote.revision+1,drafts:remote.drafts}};
|
||||
const error=new Error('conflict');error.status=409;throw error;
|
||||
}}
|
||||
remote={{revision:remote.revision+1,drafts:body.drafts}};
|
||||
return JSON.parse(JSON.stringify(remote));
|
||||
}},
|
||||
onState:state=>states.push(state),
|
||||
}});
|
||||
(async()=>{{
|
||||
let rejected=false;
|
||||
try{{await controller.sync('timmy')}}catch(error){{rejected=error.status===409}}
|
||||
const afterExhaustion={{puts,rejected,state:states.at(-1),drafts:JSON.parse(JSON.stringify(drafts))}};
|
||||
contended=false;
|
||||
const retried=await controller.retry();
|
||||
process.stdout.write(JSON.stringify({{afterExhaustion,retried,puts,state:states.at(-1),drafts,remote}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["afterExhaustion"]["puts"] == 2
|
||||
assert output["afterExhaustion"]["rejected"] is True
|
||||
assert output["afterExhaustion"]["state"] == {
|
||||
"status": "conflict",
|
||||
"message": "Draft sync needs attention. Retry when your devices finish saving.",
|
||||
"retryable": True,
|
||||
}
|
||||
assert output["afterExhaustion"]["drafts"][0]["body"] == "kept"
|
||||
assert output["retried"] is True
|
||||
assert output["state"]["status"] == "ready"
|
||||
assert output["drafts"] == output["remote"]["drafts"]
|
||||
|
||||
|
||||
def test_mounted_unfiled_draft_sync_shows_a_working_retry_action_after_exhaustion():
|
||||
assert 'id="retry-unfiled-draft-sync"' in HTML.read_text()
|
||||
script = f"""
|
||||
let clickHandler;const status={{textContent:''}};
|
||||
const button={{hidden:true,disabled:false,textContent:'Retry Draft sync',addEventListener:(_name,handler)=>clickHandler=handler}};
|
||||
global.document={{querySelector:selector=>selector==='#retry-unfiled-draft-sync'?button:(selector==='#my-work-action-status'?status:null)}};
|
||||
global.localStorage={{getItem:()=>null,setItem:()=>{{}}}};
|
||||
global.setTimeout=()=>0;global.addEventListener=()=>{{}};
|
||||
const createSync=require({json.dumps(str(SYNC))});
|
||||
let contended=true;let remote={{revision:0,drafts:[]}};
|
||||
const captures={{
|
||||
currentLogin:()=> 'timmy',exportOwned:async()=>[{{id:'local',title:'Local',body:'',saved_at:1}}],
|
||||
reconcileRemote:async()=>{{}},discard:async()=>true,
|
||||
}};
|
||||
const api=async(_url,init)=>{{
|
||||
if(!init)return remote;
|
||||
if(contended){{remote={{revision:remote.revision+1,drafts:[]}};const error=new Error('conflict');error.status=409;throw error}}
|
||||
const body=JSON.parse(init.body);remote={{revision:remote.revision+1,drafts:body.drafts}};return remote;
|
||||
}};
|
||||
(async()=>{{
|
||||
const controller=createSync.mount(captures,api);
|
||||
try{{await controller.sync('timmy')}}catch(_error){{}}
|
||||
const exhausted={{hidden:button.hidden,label:button.textContent,status:status.textContent}};
|
||||
contended=false;
|
||||
await clickHandler();
|
||||
process.stdout.write(JSON.stringify({{exhausted,after:{{hidden:button.hidden,disabled:button.disabled,status:status.textContent}},remote}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["exhausted"] == {
|
||||
"hidden": False,
|
||||
"label": "Retry Draft sync",
|
||||
"status": "Draft sync needs attention. Retry when your devices finish saving.",
|
||||
}
|
||||
assert output["after"]["hidden"] is True
|
||||
assert output["after"]["disabled"] is False
|
||||
assert [draft["id"] for draft in output["remote"]["drafts"]] == ["local"]
|
||||
|
||||
|
||||
def test_unfiled_draft_sync_coalesces_overlapping_reconnects_without_self_conflict():
|
||||
script = f"""
|
||||
const createSync=require({json.dumps(str(SYNC))});
|
||||
let remote={{revision:0,drafts:[]}}, gets=0, puts=0, release;
|
||||
const gate=new Promise(resolve=>release=resolve);
|
||||
const api=async(_url,init)=>{{
|
||||
if(!init){{gets++;if(gets===1)await gate;return JSON.parse(JSON.stringify(remote))}}
|
||||
puts++;const body=JSON.parse(init.body);
|
||||
if(body.revision!==remote.revision){{const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error}}
|
||||
remote={{revision:remote.revision+1,drafts:body.drafts}};return remote;
|
||||
}};
|
||||
const draft={{id:'local',title:'Local',body:'',saved_at:1}};
|
||||
const sync=createSync({{
|
||||
captures:{{exportOwned:async()=>[draft],mergeRemote:async()=>0,discard:async()=>true}},
|
||||
storage:{{getItem:()=>null,setItem:()=>{{}}}},fetchJson:api,
|
||||
}});
|
||||
(async()=>{{const first=sync.sync('timmy'),second=sync.sync('timmy');release();const results=await Promise.all([first,second]);process.stdout.write(JSON.stringify({{gets,puts,results,remote}}))}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["puts"] == 1
|
||||
assert output["remote"]["drafts"][0]["id"] == "local"
|
||||
assert output["results"] == [True, True]
|
||||
|
||||
|
||||
def test_unfiled_draft_sync_does_not_drop_an_account_transition_during_an_active_sync():
|
||||
script = f"""
|
||||
const createSync=require({json.dumps(str(SYNC))});
|
||||
let remote={{revision:0,drafts:[]}}, release;
|
||||
const gate=new Promise(resolve=>release=resolve);let firstGet=true;const exported=[];
|
||||
const api=async(_url,init)=>{{
|
||||
if(!init){{if(firstGet){{firstGet=false;await gate}}return JSON.parse(JSON.stringify(remote))}}
|
||||
const body=JSON.parse(init.body);remote={{revision:remote.revision+1,drafts:body.drafts}};return JSON.parse(JSON.stringify(remote));
|
||||
}};
|
||||
const captures={{
|
||||
exportOwned:async owner=>{{exported.push(owner);return [{{id:owner,title:owner,body:'',saved_at:owner==='alice'?1:2}}]}},
|
||||
reconcileRemote:async()=>{{}},discard:async()=>true,
|
||||
}};
|
||||
const sync=createSync({{captures,fetchJson:api,storage:{{getItem:()=>null,setItem:()=>{{}}}}}});
|
||||
(async()=>{{
|
||||
const alice=sync.sync('alice');const bob=sync.sync('bob');release();
|
||||
const results=await Promise.all([alice,bob]);
|
||||
process.stdout.write(JSON.stringify({{exported,results,remote}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["exported"] == ["alice", "bob"]
|
||||
assert output["results"] == [True, True]
|
||||
assert [draft["id"] for draft in output["remote"]["drafts"]] == ["bob", "alice"]
|
||||
|
||||
|
||||
def test_unfiled_draft_sync_preserves_divergent_same_draft_edits_as_a_conflict_copy():
|
||||
script = f"""
|
||||
const createSync=require({json.dumps(str(SYNC))});
|
||||
let remote={{revision:0,drafts:[]}};
|
||||
async function api(_url,init) {{
|
||||
if(!init) return JSON.parse(JSON.stringify(remote));
|
||||
const body=JSON.parse(init.body);
|
||||
if(body.revision!==remote.revision) {{
|
||||
const error=new Error('conflict');error.status=409;error.payload={{detail:{{snapshot:remote}}}};throw error;
|
||||
}}
|
||||
remote={{revision:remote.revision+1,drafts:body.drafts}};
|
||||
return JSON.parse(JSON.stringify(remote));
|
||||
}}
|
||||
function device(initial=[]) {{
|
||||
let drafts=JSON.parse(JSON.stringify(initial));
|
||||
const values=new Map(), states=[];
|
||||
const captures={{
|
||||
exportOwned:async()=>JSON.parse(JSON.stringify(drafts)),
|
||||
mergeRemote:async items=>{{for(const item of items)if(!drafts.some(x=>x.id===item.id))drafts.push(JSON.parse(JSON.stringify(item))) }},
|
||||
reconcileRemote:async items=>{{drafts=JSON.parse(JSON.stringify(items))}},
|
||||
discard:async id=>{{drafts=drafts.filter(item=>item.id!==id);return true}},
|
||||
}};
|
||||
const sync=createSync({{
|
||||
captures,fetchJson:api,
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||
createConflictId:()=> 'field-conflict-phone',onState:state=>states.push(state),
|
||||
}});
|
||||
return {{sync,states,edit(body,saved_at){{drafts[0].body=body;drafts[0].saved_at=saved_at}},drafts:()=>drafts}};
|
||||
}}
|
||||
(async()=>{{
|
||||
const original={{id:'field',title:'Field failure',body:'original',saved_at:1}};
|
||||
const phone=device([original]);
|
||||
await phone.sync.sync('timmy');
|
||||
const desktop=device();
|
||||
await desktop.sync.sync('timmy');
|
||||
desktop.edit('desktop edit',2);
|
||||
await desktop.sync.sync('timmy');
|
||||
await phone.sync.sync('timmy');
|
||||
phone.edit('phone edit',3);
|
||||
desktop.edit('desktop second edit',4);
|
||||
await desktop.sync.sync('timmy');
|
||||
await phone.sync.sync('timmy');
|
||||
process.stdout.write(JSON.stringify({{remote,phone:phone.drafts(),states:phone.states}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1)}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["remote"]["revision"] == 4
|
||||
assert [(item["id"], item["body"]) for item in output["remote"]["drafts"]] == [
|
||||
("field", "desktop second edit"),
|
||||
("field-conflict-phone", "phone edit"),
|
||||
]
|
||||
assert output["remote"]["drafts"][1]["title"] == "Field failure (conflict copy)"
|
||||
assert output["phone"] == output["remote"]["drafts"]
|
||||
assert output["states"][-1] == {
|
||||
"status": "conflict-preserved",
|
||||
"message": "Draft changed on two devices · both versions were saved.",
|
||||
}
|
||||
@@ -192,7 +192,8 @@ async def test_follow_up_capture_offers_durable_create_and_next_flow():
|
||||
assert 'id="create-follow-up-next"' in html
|
||||
assert '>Create follow-up & next</button>' in html
|
||||
assert "updateFollowUp.stageSource(source.item)" in html
|
||||
assert "event.submitter?.id === 'create-follow-up-next'" in html
|
||||
assert "event.submitter.id === 'create-follow-up-next'" in html
|
||||
assert "review.intent === 'follow-up-and-next'" in html
|
||||
assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html
|
||||
assert "advance: source => notificationReader.acceptReadAndNext(lastMyWork, source)" in html
|
||||
assert ".create-issue-actions button { min-height:44px;" in html
|
||||
@@ -41,6 +41,7 @@ async def test_work_page_endpoint_normalizes_requested_page_and_is_not_cacheable
|
||||
"items": [{
|
||||
"id": 51, "number": 51, "title": "Older issue", "state": "open",
|
||||
"labels": [], "assignees": ["timmy"], "repository": "stackchain/api",
|
||||
"work_reasons": [],
|
||||
"updated_at": "2026-08-07T10:00:00Z",
|
||||
"due_date": "2026-08-09T23:59:59Z",
|
||||
"milestone": {"id": 9, "title": "August RC"},
|
||||
@@ -66,6 +67,29 @@ async def test_work_page_endpoint_rejects_unknown_stream_before_upstream_io(monk
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_filed_work_page_endpoint_preserves_authored_reason(monkeypatch):
|
||||
async def page_loader(stream, page):
|
||||
return {
|
||||
"stream": stream, "page": page, "total": 1, "has_more": False,
|
||||
"items": [{
|
||||
"id": 870, "number": 870, "title": "Delegated", "state": "open",
|
||||
"labels": [], "assignees": [{"login": "alex"}],
|
||||
"work_reasons": ["created_by_me"],
|
||||
"repository": {"full_name": "stackchain/dashboard"},
|
||||
"html_url": "https://forge.example/issues/870",
|
||||
}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_page", page_loader)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/work/filed?page=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"][0]["work_reasons"] == ["created_by_me"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_data():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
@@ -39,6 +39,33 @@ async def test_work_route_endpoint_resolves_one_authorized_item(monkeypatch):
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_route_endpoint_accepts_filed_issue_identity(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def resolve(kind, repository, number, notification_id):
|
||||
requested.append((kind, repository, number, notification_id))
|
||||
return {
|
||||
"kind": "filed",
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"title": "Delegated filing",
|
||||
"is_filed": True,
|
||||
"is_assigned": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "resolve_work_route", resolve)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/work-route?kind=filed&repository=stackchain/dashboard&number=88"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert requested == [("filed", "stackchain/dashboard", 88, None)]
|
||||
assert response.json()["kind"] == "filed"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolve_work_route_returns_only_a_requested_review():
|
||||
requests = []
|
||||
@@ -80,6 +107,46 @@ async def test_resolve_work_route_returns_only_a_requested_review():
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolve_work_route_returns_an_open_issue_filed_by_the_current_user():
|
||||
requests = []
|
||||
|
||||
async def upstream(request):
|
||||
requests.append(request.url.path)
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
return httpx.Response(200, json={
|
||||
"id": 701,
|
||||
"number": 88,
|
||||
"title": "Delegated filing",
|
||||
"state": "open",
|
||||
"html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/88",
|
||||
"assignees": [{"login": "alex"}],
|
||||
"user": {"login": "timmy"},
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.resolve_work_route(
|
||||
"filed", "stackchain/dashboard", 88, None
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert len(requests) == 2
|
||||
assert result == {
|
||||
"kind": "filed",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 88,
|
||||
"title": "Delegated filing",
|
||||
"state": "open",
|
||||
"url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/issues/88",
|
||||
"is_filed": True,
|
||||
"is_assigned": False,
|
||||
"work_reasons": ["created_by_me"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolve_work_route_rejects_a_notification_that_is_already_read():
|
||||
async def upstream(request):
|
||||
|
||||
Reference in new issue
Block a user