70a813edfa
- whitelist HTML sanitizer (stdlib tokenizer) with XSS vector tests - developer board: pool-scoped visibility, customer/search/minBounty/sort filters, stale-task age badges, competing-claims visibility setting, saved filters in profile extras - claims: request/withdraw (developer), approve/decline (consultant) with notifications to winners and losers; unassign/abandon back to board - work tracking: start, sanitized comments with @mention notifications, time logging, submit for review - review queue + review with per-AC checklist stored on the timeline; approve writes the immutable bountyAwards row (human assignees only) - assign-to-AI: §5.2 job submission, HMAC-verified callback endpoint, idempotent by jobId, artifacts downloaded into GridFS, failure path keeps the task assigned with timeline + notification - notifications API + bell with unread badge, dropdown, page, WS toasts - pages: bounty board, my-tasks kanban, task detail (role-driven actions, review dialog, AI dialog), review queue, developer pool Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
259 lines
11 KiB
JavaScript
259 lines
11 KiB
JavaScript
import { api, toast } from '/static/js/api.js';
|
|
import { subscribe } from '/static/js/ws.js';
|
|
|
|
const root = document.getElementById('task-root');
|
|
const errorBox = document.getElementById('error');
|
|
const taskId = root.dataset.taskId;
|
|
const meId = root.dataset.userId;
|
|
const isConsultant = root.dataset.isConsultant === '1' || root.dataset.isAdmin === '1';
|
|
|
|
let task = null;
|
|
const userCache = new Map();
|
|
|
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
}[c]));
|
|
|
|
async function userName(id) {
|
|
if (!id || id.startsWith('system')) return id || 'system';
|
|
if (!userCache.has(id)) {
|
|
try {
|
|
const res = await api('GET', `/api/v1/users/${id}/card`);
|
|
userCache.set(id, res.card.name);
|
|
} catch (e) { userCache.set(id, id.slice(0, 8)); }
|
|
}
|
|
return userCache.get(id);
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
const res = await api('GET', `/api/v1/tasks/${taskId}`);
|
|
task = res.task;
|
|
await render();
|
|
} catch (e) {
|
|
errorBox.textContent = e.message;
|
|
root.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
const mine = () => task.assignee && task.assignee.kind === 'human' && task.assignee.userId === meId;
|
|
const myClaim = () => (task.claimRequests || []).some((c) => c.developerId === meId);
|
|
|
|
async function render() {
|
|
const t = task;
|
|
const assigneeName = t.assignee
|
|
? (t.assignee.kind === 'ai' ? `AI (job ${esc(t.assignee.jobId || '')})` : esc(await userName(t.assignee.userId)))
|
|
: '—';
|
|
const orphan = t.external && t.external.orphaned
|
|
? '<span class="badge" style="color:var(--err)">orphaned upstream</span>' : '';
|
|
|
|
const claims = await Promise.all((t.claimRequests || []).map(async (c) => `
|
|
<li>${esc(await userName(c.developerId))}${c.note ? ' — ' + esc(c.note) : ''}
|
|
${isConsultant ? `
|
|
<button class="btn small primary" data-approve="${esc(c.developerId)}">Approve</button>
|
|
<button class="btn small" data-decline="${esc(c.developerId)}">Decline</button>` : ''}
|
|
</li>`));
|
|
|
|
const comments = await Promise.all((t.comments || []).map(async (c) => `
|
|
<div class="card" style="padding:12px">
|
|
<p class="muted" style="margin:0 0 8px">${esc(await userName(c.authorId))} · ${new Date(c.at).toLocaleString()}</p>
|
|
<div>${c.body}</div>
|
|
</div>`));
|
|
|
|
const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => `
|
|
<tr><td>${new Date(e.at).toLocaleString()}</td>
|
|
<td>${esc(await userName(e.actorId))}</td>
|
|
<td>${esc(e.event)}${e.data && e.data.note ? ' — ' + esc(e.data.note) : ''}</td></tr>`));
|
|
|
|
const timeRows = (t.timeLog || []).map((e) =>
|
|
`<tr><td>${new Date(e.at).toLocaleString()}</td><td>${e.minutes} min</td><td>${esc(e.note)}</td></tr>`).join('');
|
|
const totalMin = (t.timeLog || []).reduce((a, e) => a + e.minutes, 0);
|
|
|
|
root.innerHTML = `
|
|
<div class="spread">
|
|
<h1>${esc(t.title)}</h1>
|
|
<span class="badge accent" style="font-size:1.1rem">◈ ${t.bounty}</span>
|
|
</div>
|
|
<p class="muted">
|
|
<span class="badge">${esc(t.status)}</span> ${orphan}
|
|
${t.external ? `· <a href="${esc(t.external.url)}" target="_blank" rel="noopener">${esc(t.external.key)} ↗</a>` : ''}
|
|
· assignee: ${assigneeName}
|
|
${totalMin ? `· time logged: ${Math.floor(totalMin / 60)}h ${totalMin % 60}m` : ''}
|
|
</p>
|
|
<div id="actions" class="mt"></div>
|
|
<div class="card mt">
|
|
<h2>Description</h2>
|
|
<p style="white-space:pre-wrap">${esc(t.description)}</p>
|
|
${(t.acceptanceCriteria || []).length ? `
|
|
<h3>Acceptance criteria</h3>
|
|
<ul>${t.acceptanceCriteria.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>` : ''}
|
|
${(t.attachments || []).length ? `
|
|
<h3>Attachments</h3>
|
|
<ul>${t.attachments.map((a) => `<li><a href="${a.fileId ? '/files/' + a.fileId : esc(a.url)}" target="_blank" rel="noopener">${esc(a.name)}</a></li>`).join('')}</ul>` : ''}
|
|
${(t.links || []).length ? `
|
|
<h3>Links</h3>
|
|
<ul>${t.links.map((l) => `<li><a href="${esc(l)}" target="_blank" rel="noopener">${esc(l)}</a></li>`).join('')}</ul>` : ''}
|
|
</div>
|
|
${(t.claimRequests || []).length ? `
|
|
<div class="card mt"><h2>Assignment requests</h2><ul>${claims.join('')}</ul></div>` : ''}
|
|
<div class="card mt">
|
|
<h2>Comments</h2>
|
|
<div class="stack">${comments.join('') || '<p class="muted">No comments yet.</p>'}</div>
|
|
<form id="comment-form" class="mt">
|
|
<div class="field">
|
|
<label for="comment-body">Add comment (mention with @name or @email)</label>
|
|
<textarea id="comment-body" required></textarea>
|
|
</div>
|
|
<button class="btn primary" type="submit">Comment</button>
|
|
</form>
|
|
</div>
|
|
${mine() ? `
|
|
<div class="card mt">
|
|
<h2>Log time</h2>
|
|
<form id="time-form" class="row">
|
|
<div class="field"><label for="time-minutes">Minutes</label>
|
|
<input type="number" id="time-minutes" min="1" max="1440" required></div>
|
|
<div class="field"><label for="time-note">Note</label>
|
|
<input type="text" id="time-note"></div>
|
|
<div class="field" style="flex:0;align-self:flex-end">
|
|
<button class="btn primary" type="submit">Log</button></div>
|
|
</form>
|
|
${timeRows ? `<table class="list"><thead><tr><th>When</th><th>Time</th><th>Note</th></tr></thead><tbody>${timeRows}</tbody></table>` : ''}
|
|
</div>` : ''}
|
|
<div class="card mt">
|
|
<h2>Timeline</h2>
|
|
<table class="list"><thead><tr><th>When</th><th>Who</th><th>Event</th></tr></thead>
|
|
<tbody>${timeline.join('')}</tbody></table>
|
|
</div>`;
|
|
|
|
renderActions();
|
|
wireHandlers();
|
|
}
|
|
|
|
function renderActions() {
|
|
const host = document.getElementById('actions');
|
|
const t = task;
|
|
const add = (label, fn, primary) => {
|
|
const b = document.createElement('button');
|
|
b.className = 'btn' + (primary ? ' primary' : '');
|
|
b.style.marginRight = '8px';
|
|
b.textContent = label;
|
|
b.addEventListener('click', fn);
|
|
host.appendChild(b);
|
|
};
|
|
const post = (path, body = {}) => async () => {
|
|
try { await api('POST', `/api/v1/tasks/${taskId}/${path}`, body); load(); }
|
|
catch (e) { toast(e.message, 'err'); }
|
|
};
|
|
|
|
if (root.dataset.isDeveloper === '1') {
|
|
if (t.status === 'published' && !myClaim()) add('Request assignment', post('claim', { note: '' }), true);
|
|
if (myClaim() && ['published', 'claim_requested'].includes(t.status)) add('Withdraw request', post('claim/withdraw'));
|
|
if (mine() && ['assigned', 'changes_requested'].includes(t.status)) add('Start work', post('start'), true);
|
|
if (mine() && t.status === 'in_progress') add('Submit for review', post('submit-review'), true);
|
|
if (mine() && ['assigned', 'in_progress'].includes(t.status)) add('Abandon', post('abandon'));
|
|
}
|
|
if (isConsultant) {
|
|
if (t.status === 'in_review') {
|
|
add('Review…', openReview, true);
|
|
}
|
|
if (t.status === 'published') add('Assign to AI…', () => document.getElementById('ai-dialog').showModal(), false);
|
|
if (['assigned', 'in_progress'].includes(t.status)) add('Unassign', post('unassign'));
|
|
if (t.status !== 'archived' && t.status !== 'approved') add('Archive', post('archive'));
|
|
}
|
|
}
|
|
|
|
function wireHandlers() {
|
|
document.querySelectorAll('[data-approve]').forEach((b) => {
|
|
b.addEventListener('click', async () => {
|
|
try {
|
|
await api('POST', `/api/v1/tasks/${taskId}/approve-claim`, { developerId: b.dataset.approve });
|
|
load();
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
});
|
|
});
|
|
document.querySelectorAll('[data-decline]').forEach((b) => {
|
|
b.addEventListener('click', async () => {
|
|
try {
|
|
await api('POST', `/api/v1/tasks/${taskId}/decline-claim`, { developerId: b.dataset.decline });
|
|
load();
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
});
|
|
});
|
|
document.getElementById('comment-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const body = document.getElementById('comment-body').value;
|
|
try {
|
|
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + esc(body).replace(/\n/g, '<br>') + '</p>' });
|
|
load();
|
|
} catch (err) { toast(err.message, 'err'); }
|
|
});
|
|
const timeForm = document.getElementById('time-form');
|
|
if (timeForm) {
|
|
timeForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
await api('POST', `/api/v1/tasks/${taskId}/time`, {
|
|
minutes: parseInt(document.getElementById('time-minutes').value, 10),
|
|
note: document.getElementById('time-note').value.trim(),
|
|
});
|
|
load();
|
|
} catch (err) { toast(err.message, 'err'); }
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---- review dialog with per-AC checklist (§11.18) ----
|
|
const reviewDlg = document.getElementById('review-dialog');
|
|
function openReview() {
|
|
const host = document.getElementById('review-checklist');
|
|
host.replaceChildren(...(task.acceptanceCriteria || []).map((c, i) => {
|
|
const div = document.createElement('div');
|
|
div.innerHTML = `<label style="font-weight:normal">
|
|
<input type="checkbox" data-ac="${i}" checked> ${esc(c)}</label>`;
|
|
return div;
|
|
}));
|
|
reviewDlg.showModal();
|
|
}
|
|
document.getElementById('review-cancel').addEventListener('click', () => reviewDlg.close());
|
|
|
|
async function submitReview(decision) {
|
|
const checklist = [...document.querySelectorAll('[data-ac]')].map((cb, i) => ({
|
|
criterion: task.acceptanceCriteria[i], ok: cb.checked,
|
|
}));
|
|
try {
|
|
await api('POST', `/api/v1/tasks/${taskId}/review`, {
|
|
decision, note: document.getElementById('review-note').value.trim(), checklist,
|
|
});
|
|
reviewDlg.close();
|
|
toast(decision === 'approve' ? 'Approved — bounty awarded.' : 'Changes requested.', 'ok');
|
|
load();
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
}
|
|
document.getElementById('review-approve').addEventListener('click', () => submitReview('approve'));
|
|
document.getElementById('review-changes').addEventListener('click', () => submitReview('request_changes'));
|
|
|
|
// ---- AI dialog ----
|
|
const aiDlg = document.getElementById('ai-dialog');
|
|
document.getElementById('ai-cancel').addEventListener('click', () => aiDlg.close());
|
|
document.getElementById('ai-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
await api('POST', `/api/v1/tasks/${taskId}/assign-ai`, {
|
|
context: {
|
|
repositoryUrl: document.getElementById('ai-repo').value.trim(),
|
|
branch: document.getElementById('ai-branch').value.trim(),
|
|
instructions: document.getElementById('ai-instructions').value.trim(),
|
|
},
|
|
});
|
|
aiDlg.close();
|
|
toast('AI job created — the result will arrive for review.', 'ok');
|
|
load();
|
|
} catch (err) { toast(err.message, 'err'); }
|
|
});
|
|
|
|
subscribe('board', (event, data) => {
|
|
if (data && data.taskId === taskId) setTimeout(load, 200);
|
|
});
|
|
load();
|