feat: assignee field in feedback widget
All checks were successful
Deploy to dev.bl.pixeldev.eu / deploy (push) Successful in 2s

- New GET /api/collaborators?repo= endpoint fetches repo owner + collabs
- Assignee dropdown added to the new issue form (lazy-loaded on first open)
- Selected assignee is forwarded to Gitea via the assignees[] field
- Dropdown resets to Unassigned after successful submission

Closes #5
This commit is contained in:
OpenClaw Agent 2026-03-13 13:59:27 +00:00
parent be8c814da6
commit 7a593d082a

View File

@ -71,14 +71,33 @@ app.get(['/api/issues', BASE + '/api/issues'], async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// GET /api/collaborators?repo=owner/repo
app.get(['/api/collaborators', BASE + '/api/collaborators'], async (req, res) => {
const { repo } = req.query;
if (!repo) return res.status(400).json({ error: 'repo required' });
try {
const users = await giteaRequest('GET', `/api/v1/repos/${repo}/collaborators?limit=50`);
// Also include the owner
const repoInfo = await giteaRequest('GET', `/api/v1/repos/${repo}`);
const owner = repoInfo.owner ? [{ login: repoInfo.owner.login }] : [];
const collabs = Array.isArray(users) ? users.map(u => ({ login: u.login })) : [];
// Dedupe
const seen = new Set();
const all = [...owner, ...collabs].filter(u => { if (seen.has(u.login)) return false; seen.add(u.login); return true; });
res.json(all);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/feedback — create new issue // POST /api/feedback — create new issue
app.post(['/api/feedback', BASE + '/api/feedback'], async (req, res) => { app.post(['/api/feedback', BASE + '/api/feedback'], async (req, res) => {
const { title, body, repo, source_url } = req.body; const { title, body, repo, source_url, assignee } = req.body;
if (!title) return res.status(400).json({ error: 'title required' }); if (!title) return res.status(400).json({ error: 'title required' });
if (!repo) return res.status(400).json({ error: 'repo required' }); if (!repo) return res.status(400).json({ error: 'repo required' });
const issueBody = [body || '', '', '---', `*Submitted via feedback widget from: ${source_url || 'unknown'}*`].join('\n'); const issueBody = [body || '', '', '---', `*Submitted via feedback widget from: ${source_url || 'unknown'}*`].join('\n');
const payload = { title, body: issueBody };
if (assignee) payload.assignees = [assignee];
try { try {
const issue = await giteaRequest('POST', `/api/v1/repos/${repo}/issues`, { title, body: issueBody }); const issue = await giteaRequest('POST', `/api/v1/repos/${repo}/issues`, payload);
if (issue.number) res.json({ ok: true, issue_number: issue.number, issue_url: issue.html_url }); if (issue.number) res.json({ ok: true, issue_number: issue.number, issue_url: issue.html_url });
else res.status(500).json({ error: 'Failed', raw: JSON.stringify(issue).slice(0, 200) }); else res.status(500).json({ error: 'Failed', raw: JSON.stringify(issue).slice(0, 200) });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
@ -151,6 +170,14 @@ app.get(['/widget.js', BASE + '/widget.js'], (req, res) => {
.fb-issue-arrow { color:#444; font-size:.8rem; flex-shrink:0; } .fb-issue-arrow { color:#444; font-size:.8rem; flex-shrink:0; }
.fb-empty { color:#555; font-size:.85rem; text-align:center; padding:1.5rem 0; } .fb-empty { color:#555; font-size:.85rem; text-align:center; padding:1.5rem 0; }
.fb-loading { color:#555; font-size:.85rem; text-align:center; padding:1rem 0; } .fb-loading { color:#555; font-size:.85rem; text-align:center; padding:1rem 0; }
/* Assignee select */
.fb-select { background:#0d0d0d; border:1px solid #2a2a2a; border-radius:8px;
padding:.6rem .8rem; color:#e0e0e0; font-size:.875rem; width:100%; font-family:inherit;
outline:none; transition:border-color .15s; appearance:none;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat:no-repeat; background-position:right .7rem center; padding-right:2rem; }
.fb-select:focus { border-color:#7c9ef5; }
.fb-field-label { color:#666; font-size:.75rem; margin-bottom:.2rem; }
/* Picker row inside new-issue form */ /* Picker row inside new-issue form */
.fb-picker-row { display:flex; align-items:center; gap:.6rem; } .fb-picker-row { display:flex; align-items:center; gap:.6rem; }
.fb-picker-btn { background:#1a2a1a; border:1px solid #2a4a2a; color:#6fcf6f; font-size:.78rem; .fb-picker-btn { background:#1a2a1a; border:1px solid #2a4a2a; color:#6fcf6f; font-size:.78rem;
@ -205,6 +232,12 @@ app.get(['/widget.js', BASE + '/widget.js'], (req, res) => {
<div style="display:flex;flex-direction:column;gap:.8rem;"> <div style="display:flex;flex-direction:column;gap:.8rem;">
<input class="fb-input" id="_fb-title" type="text" placeholder="Title (required)" /> <input class="fb-input" id="_fb-title" type="text" placeholder="Title (required)" />
<textarea class="fb-textarea" id="_fb-desc" placeholder="Describe your feedback…"></textarea> <textarea class="fb-textarea" id="_fb-desc" placeholder="Describe your feedback…"></textarea>
<div>
<div class="fb-field-label">Assignee</div>
<select class="fb-select" id="_fb-assignee">
<option value=""> Unassigned </option>
</select>
</div>
<div class="fb-picker-row"> <div class="fb-picker-row">
<button class="fb-picker-btn" id="_fb-pick-btn">🎯 Pick element</button> <button class="fb-picker-btn" id="_fb-pick-btn">🎯 Pick element</button>
<span class="fb-picker-hint" id="_fb-pick-hint">Click to reference a page element</span> <span class="fb-picker-hint" id="_fb-pick-hint">Click to reference a page element</span>
@ -224,8 +257,26 @@ app.get(['/widget.js', BASE + '/widget.js'], (req, res) => {
\`; \`;
document.body.appendChild(overlay); document.body.appendChild(overlay);
// ── Load collaborators into assignee dropdown ─────────────────────────────
var assigneesLoaded = false;
function loadAssignees() {
if (assigneesLoaded || !cfg.repo) return;
assigneesLoaded = true;
fetch(SELF + '/api/collaborators?repo=' + encodeURIComponent(cfg.repo))
.then(function(r) { return r.json(); })
.then(function(users) {
var sel = document.getElementById('_fb-assignee');
if (!Array.isArray(users)) return;
users.forEach(function(u) {
var opt = document.createElement('option');
opt.value = u.login; opt.textContent = u.login;
sel.appendChild(opt);
});
}).catch(function() {});
}
// ── Open / close ────────────────────────────────────────────────────────── // ── Open / close ──────────────────────────────────────────────────────────
function openOverlay() { overlay.classList.add('open'); } function openOverlay() { overlay.classList.add('open'); loadAssignees(); }
function closeOverlay() { overlay.classList.remove('open'); } function closeOverlay() { overlay.classList.remove('open'); }
fab.addEventListener('click', function() { fab.addEventListener('click', function() {
@ -347,19 +398,21 @@ app.get(['/widget.js', BASE + '/widget.js'], (req, res) => {
document.getElementById('_fb-submit').addEventListener('click', function() { document.getElementById('_fb-submit').addEventListener('click', function() {
var title = document.getElementById('_fb-title').value.trim(); var title = document.getElementById('_fb-title').value.trim();
var body = document.getElementById('_fb-desc').value.trim(); var body = document.getElementById('_fb-desc').value.trim();
var assignee = document.getElementById('_fb-assignee').value;
var status = document.getElementById('_fb-status'); var status = document.getElementById('_fb-status');
var btn = document.getElementById('_fb-submit'); var btn = document.getElementById('_fb-submit');
if (!title) { status.style.color='#f57c7c'; status.textContent='Title is required.'; return; } if (!title) { status.style.color='#f57c7c'; status.textContent='Title is required.'; return; }
btn.disabled = true; status.style.color='#888'; status.textContent='Submitting…'; btn.disabled = true; status.style.color='#888'; status.textContent='Submitting…';
fetch(SELF + '/api/feedback', { fetch(SELF + '/api/feedback', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, body, repo: cfg.repo, source_url: window.location.href }) body: JSON.stringify({ title, body, repo: cfg.repo, source_url: window.location.href, assignee: assignee || undefined })
}).then(function(r) { return r.json(); }).then(function(d) { }).then(function(r) { return r.json(); }).then(function(d) {
if (d.ok) { if (d.ok) {
status.style.color='#6fcf6f'; status.style.color='#6fcf6f';
status.textContent='✓ Issue #' + d.issue_number + ' created!'; status.textContent='✓ Issue #' + d.issue_number + ' created!';
document.getElementById('_fb-title').value = ''; document.getElementById('_fb-title').value = '';
document.getElementById('_fb-desc').value = ''; document.getElementById('_fb-desc').value = '';
document.getElementById('_fb-assignee').value = '';
document.getElementById('_fb-snippet-wrap').style.display = 'none'; document.getElementById('_fb-snippet-wrap').style.display = 'none';
document.getElementById('_fb-pick-hint').textContent = 'Click to reference a page element'; document.getElementById('_fb-pick-hint').textContent = 'Click to reference a page element';
capturedSnippet = ''; capturedSnippet = '';