diff --git a/public/plugins/gitea/gitea.js b/public/plugins/gitea/gitea.js
index d81b93b..eca1b16 100644
--- a/public/plugins/gitea/gitea.js
+++ b/public/plugins/gitea/gitea.js
@@ -488,6 +488,9 @@ function renderDetail() {
link.style.display = 'none';
}
+ // Metadata row (assignees / milestone / due date)
+ renderMetaRow();
+
// Comments — delegated to its own function so we can re-render after posting
renderCommentsSection();
@@ -498,17 +501,184 @@ function renderDetail() {
cancelTitleEdit();
cancelBodyEdit();
closeLabelsEditor();
+ closeMetaEditor();
setExtractStatus('');
$('gitea-comment-input').value = '';
$('gitea-comment-status').textContent = '';
}
+// ── Metadata row + editor (assignees, milestone, due date) ──────
+
+function renderMetaRow() {
+ if (!currentIssue) return;
+ const i = currentIssue;
+ const parts = [];
+
+ // Assignees
+ if (i.assignees && i.assignees.length) {
+ parts.push(`
${i.assignees.map(escapeHtml).join(', ')}`);
+ } else {
+ parts.push(`
unassigned`);
+ }
+
+ // Milestone
+ if (i.milestone && i.milestone.title) {
+ parts.push(`
${escapeHtml(i.milestone.title)}`);
+ } else {
+ parts.push(`
no milestone`);
+ }
+
+ // Due date
+ if (i.due_date) {
+ const ds = i.due_date.substring(0, 10);
+ parts.push(`
${escapeHtml(ds)}`);
+ } else {
+ parts.push(`
no due date`);
+ }
+
+ $('gitea-detail-meta').innerHTML = parts.join('');
+}
+
+let metaSelectedAssignees = new Set();
+
+async function openMetaEditor() {
+ if (!currentIssue) return;
+ const editor = $('gitea-meta-editor');
+ editor.classList.remove('hidden');
+ closeLabelsEditor(); // don't show both at once
+
+ const i = currentIssue;
+ metaSelectedAssignees = new Set(i.assignees || []);
+
+ // Pre-fill due date input
+ $('gitea-meta-due').value = i.due_date ? i.due_date.substring(0, 10) : '';
+
+ // Loading placeholders
+ $('gitea-meta-assignees').innerHTML = '
loading...
';
+ $('gitea-meta-milestone').innerHTML = '
';
+
+ const ownerEnc = encodeURIComponent(i.owner);
+ const repoEnc = encodeURIComponent(i.repo_name);
+ const base = `api/gitea/repo/${currentAccountId}/${ownerEnc}/${repoEnc}`;
+
+ const [collabsRes, milestonesRes] = await Promise.allSettled([
+ fetch(`${base}/collaborators`).then(r => r.json()),
+ fetch(`${base}/milestones`).then(r => r.json()),
+ ]);
+
+ // ── Collaborators (assignees) ──
+ const assigneesEl = $('gitea-meta-assignees');
+ if (collabsRes.status === 'fulfilled' && collabsRes.value.ok) {
+ const collabs = collabsRes.value.collaborators || [];
+ if (!collabs.length) {
+ assigneesEl.innerHTML = '
no collaborators
';
+ } else {
+ // Make sure currently-assigned users are present even if missing
+ // from the collaborator list (e.g. they were assigned before being
+ // removed as a collaborator)
+ const knownLogins = new Set(collabs.map(u => u.login));
+ const extras = (i.assignees || []).filter(login => !knownLogins.has(login)).map(login => ({ login, full_name: '' }));
+ const all = [...collabs, ...extras];
+ assigneesEl.innerHTML = all.map(u => `
+
+ `).join('');
+ assigneesEl.querySelectorAll('.gitea-label-checkbox input').forEach(cb => {
+ cb.addEventListener('change', () => {
+ const login = cb.closest('.gitea-label-checkbox').dataset.login;
+ if (cb.checked) metaSelectedAssignees.add(login);
+ else metaSelectedAssignees.delete(login);
+ });
+ });
+ }
+ } else {
+ const err = collabsRes.status === 'fulfilled' ? collabsRes.value.error : collabsRes.reason?.message;
+ assigneesEl.innerHTML = `
cannot list collaborators: ${escapeHtml(err || 'failed')}
`;
+ }
+
+ // ── Milestones ──
+ const milestoneSel = $('gitea-meta-milestone');
+ if (milestonesRes.status === 'fulfilled' && milestonesRes.value.ok) {
+ const milestones = milestonesRes.value.milestones || [];
+ const currentId = i.milestone?.id;
+ let html = '
';
+ if (!milestones.length && !currentId) {
+ html = '
';
+ }
+ // If current milestone exists but isn't in the open list (e.g. closed),
+ // include it explicitly so we don't lose the selection
+ if (currentId && !milestones.find(m => m.id === currentId)) {
+ html += `
`;
+ }
+ html += milestones.map(ms =>
+ `
`
+ ).join('');
+ milestoneSel.innerHTML = html;
+ } else {
+ milestoneSel.innerHTML = '
';
+ }
+}
+
+function closeMetaEditor() {
+ $('gitea-meta-editor').classList.add('hidden');
+}
+
+function clearMetaDue() {
+ $('gitea-meta-due').value = '';
+}
+
+async function saveMetaEditor() {
+ if (!currentIssue) return;
+ setExtractStatus('saving metadata...');
+
+ const payload = {};
+ payload.assignees = [...metaSelectedAssignees];
+
+ const milestoneVal = $('gitea-meta-milestone').value;
+ payload.milestone = milestoneVal ? parseInt(milestoneVal) : 0; // 0 = clear in gitea
+
+ const dueVal = $('gitea-meta-due').value;
+ if (dueVal) {
+ // Gitea expects RFC3339; append T00:00:00Z to make it valid
+ payload.due_date = dueVal + 'T00:00:00Z';
+ } else {
+ payload.unset_due_date = true;
+ }
+
+ try {
+ const res = await patchIssue(payload);
+ if (!res.ok) {
+ setExtractStatus('error: ' + (res.error || 'failed'), 'error');
+ return;
+ }
+ // Refresh local state from the latest detail call (PATCH only returns
+ // a subset, so re-fetch for accuracy)
+ const detailRes = await fetch(`api/gitea/issue/${currentAccountId}/${encodeURIComponent(currentIssue.owner)}/${encodeURIComponent(currentIssue.repo_name)}/${currentIssue.number}`);
+ const detailData = await detailRes.json();
+ if (detailData.ok) {
+ currentIssue = { ...detailData.issue, comments: currentIssue.comments };
+ renderMetaRow();
+ }
+ closeMetaEditor();
+ setExtractStatus('metadata updated', 'success');
+ updateListRow();
+ } catch (err) {
+ setExtractStatus('error: ' + err.message, 'error');
+ }
+}
+
// ── Create issue ──────────────────────────────────────────────────
let cachedRepos = null; // per-account cache
let cachedReposForAccount = null;
let cachedReposError = null; // last error message for repo fetch (so it shows on reopen)
let createSelectedLabels = new Set();
+let createSelectedAssignees = new Set();
+let createLastLoadedRepo = null; // dedupe: don't reload metadata if value hasn't changed
function setCreateStatus(text, kind = 'info') {
const el = $('gitea-create-status');
@@ -549,27 +719,29 @@ async function openCreateForm() {
$('gitea-create-body').value = '';
setCreateStatus('');
createSelectedLabels.clear();
+ createSelectedAssignees.clear();
+ createLastLoadedRepo = null;
$('gitea-create-labels').innerHTML = '
pick a repo first
';
+ $('gitea-create-assignees').innerHTML = '
pick a repo first
';
+ $('gitea-create-milestone').innerHTML = '
';
- // Try to populate the datalist autocomplete with the user's repos.
+ // Try to load the user's repos for the custom autocomplete dropdown.
// If the token lacks read:repository scope this will 403 — the input is
// still usable manually, but we surface the error so the user knows why
// autocomplete isn't working and can update the token if they want.
- const datalist = $('gitea-create-repo-list');
const hint = $('gitea-create-repo-hint');
const errBox = $('gitea-create-repo-error');
errBox.classList.add('hidden');
errBox.textContent = '';
+ hideRepoDropdown();
if (cachedReposForAccount === currentAccountId && cachedRepos) {
- renderRepoDatalist(cachedRepos);
- // If the cached fetch had failed, also restore the error message
+ updateRepoHint();
if (cachedReposError) {
errBox.textContent = cachedReposError;
errBox.classList.remove('hidden');
}
} else {
- datalist.innerHTML = '';
hint.textContent = '(loading suggestions...)';
try {
const res = await fetch(`api/gitea/repos?account=${currentAccountId}`);
@@ -586,7 +758,7 @@ async function openCreateForm() {
cachedRepos = data.repos || [];
cachedReposForAccount = currentAccountId;
cachedReposError = null;
- renderRepoDatalist(cachedRepos);
+ updateRepoHint();
} catch (err) {
hint.textContent = '(type owner/repo manually)';
cachedReposError = 'Network error loading repos: ' + err.message;
@@ -596,27 +768,108 @@ async function openCreateForm() {
}
}
-function renderRepoDatalist(repos) {
- const datalist = $('gitea-create-repo-list');
+function updateRepoHint() {
const hint = $('gitea-create-repo-hint');
- if (!repos.length) {
- datalist.innerHTML = '';
- hint.textContent = '(type owner/repo manually)';
+ const n = (cachedRepos || []).length;
+ hint.textContent = n > 0 ? `(${n} suggestion${n === 1 ? '' : 's'})` : '(type owner/repo manually)';
+}
+
+// ── Custom repo autocomplete dropdown ─────────────────────────────
+
+let repoDropdownActive = -1;
+
+function showRepoDropdown() {
+ const dropdown = $('gitea-create-repo-dropdown');
+ const input = $('gitea-create-repo');
+ if (!dropdown || !input) return;
+ if (!cachedRepos || !cachedRepos.length) return;
+
+ const query = input.value.trim().toLowerCase();
+ const matches = query
+ ? cachedRepos.filter(r => (r.full_name || '').toLowerCase().includes(query))
+ : cachedRepos.slice();
+
+ if (!matches.length) {
+ hideRepoDropdown();
return;
}
- datalist.innerHTML = repos.map(r =>
- `
`
- ).join('');
- hint.textContent = `(${repos.length} suggestion${repos.length === 1 ? '' : 's'})`;
+
+ // Cap to first 50 to keep DOM cheap
+ const display = matches.slice(0, 50);
+ dropdown.innerHTML = display.map((r, idx) => `
+
+
+ ${escapeHtml(r.full_name)}
+ ${r.private ? 'private' : ''}
+
+ `).join('');
+ dropdown.classList.remove('hidden');
+ repoDropdownActive = -1;
+
+ dropdown.querySelectorAll('.gitea-repo-option').forEach(opt => {
+ // Use mousedown so the click registers BEFORE input blur fires
+ opt.addEventListener('mousedown', e => {
+ e.preventDefault();
+ input.value = opt.dataset.value;
+ hideRepoDropdown();
+ loadCreateRepoLabels();
+ });
+ });
+}
+
+function hideRepoDropdown() {
+ const dropdown = $('gitea-create-repo-dropdown');
+ if (!dropdown) return;
+ dropdown.classList.add('hidden');
+ dropdown.innerHTML = '';
+ repoDropdownActive = -1;
+}
+
+function moveRepoDropdownActive(delta) {
+ const dropdown = $('gitea-create-repo-dropdown');
+ if (!dropdown || dropdown.classList.contains('hidden')) return;
+ const opts = dropdown.querySelectorAll('.gitea-repo-option');
+ if (!opts.length) return;
+ repoDropdownActive = (repoDropdownActive + delta + opts.length) % opts.length;
+ opts.forEach((o, i) => o.classList.toggle('active', i === repoDropdownActive));
+ opts[repoDropdownActive].scrollIntoView({ block: 'nearest' });
+}
+
+function commitRepoDropdownActive() {
+ const dropdown = $('gitea-create-repo-dropdown');
+ if (!dropdown || dropdown.classList.contains('hidden')) return false;
+ const opts = dropdown.querySelectorAll('.gitea-repo-option');
+ if (repoDropdownActive < 0 || repoDropdownActive >= opts.length) return false;
+ const opt = opts[repoDropdownActive];
+ $('gitea-create-repo').value = opt.dataset.value;
+ hideRepoDropdown();
+ loadCreateRepoLabels();
+ return true;
}
async function loadCreateRepoLabels() {
+ // Loads labels + milestones + collaborators in parallel for the picked
+ // repo. Deduped: if the same repo value is passed again (e.g. blur fires
+ // change after a dropdown click already triggered a load), we skip —
+ // otherwise we'd wipe the user's already-checked labels/assignees.
const input = $('gitea-create-repo');
const fullName = input.value.trim();
+
+ if (fullName === createLastLoadedRepo) return; // already loaded → don't reset
+
const labelsEl = $('gitea-create-labels');
+ const assigneesEl = $('gitea-create-assignees');
+ const milestoneSel = $('gitea-create-milestone');
+
+ // Now we know we're loading a different repo — safe to clear selections
createSelectedLabels.clear();
+ createSelectedAssignees.clear();
+ createLastLoadedRepo = fullName;
+
if (!fullName || !fullName.includes('/')) {
labelsEl.innerHTML = '
pick a repo first (owner/repo)
';
+ assigneesEl.innerHTML = '
pick a repo first
';
+ milestoneSel.innerHTML = '
';
return;
}
const [owner, repo] = fullName.split('/');
@@ -624,34 +877,95 @@ async function loadCreateRepoLabels() {
labelsEl.innerHTML = '
format: owner/repo
';
return;
}
- labelsEl.innerHTML = '
loading labels...
';
- try {
- const res = await fetch(`api/gitea/repo/${currentAccountId}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/labels`);
- const data = await res.json();
- if (!data.ok) {
- labelsEl.innerHTML = `
${escapeHtml(data.error || 'failed')}
`;
- return;
+
+ labelsEl.innerHTML = '
loading...
';
+ assigneesEl.innerHTML = '
loading...
';
+ milestoneSel.innerHTML = '
';
+
+ const ownerEnc = encodeURIComponent(owner);
+ const repoEnc = encodeURIComponent(repo);
+ const base = `api/gitea/repo/${currentAccountId}/${ownerEnc}/${repoEnc}`;
+
+ // Fetch all three in parallel — each one may independently fail (e.g.
+ // collaborators 403 if the token can't see them)
+ const [labelsRes, milestonesRes, collabsRes] = await Promise.allSettled([
+ fetch(`${base}/labels`).then(r => r.json()),
+ fetch(`${base}/milestones`).then(r => r.json()),
+ fetch(`${base}/collaborators`).then(r => r.json()),
+ ]);
+
+ // ── Labels ──
+ const warningEl = $('gitea-create-labels-warning');
+ warningEl.classList.add('hidden');
+ warningEl.textContent = '';
+ if (labelsRes.status === 'fulfilled' && labelsRes.value.ok) {
+ const labels = labelsRes.value.labels || [];
+ // Surface backend warning (e.g. missing read:organization scope)
+ if (labelsRes.value.warning) {
+ warningEl.textContent = '⚠ ' + labelsRes.value.warning;
+ warningEl.classList.remove('hidden');
}
- const labels = data.labels || [];
if (!labels.length) {
- labelsEl.innerHTML = '
repo has no labels
';
- return;
- }
- labelsEl.innerHTML = labels.map(l => `
-
- `).join('');
- labelsEl.querySelectorAll('.gitea-label-checkbox input').forEach(cb => {
- cb.addEventListener('change', () => {
- const id = parseInt(cb.closest('.gitea-label-checkbox').dataset.id);
- if (cb.checked) createSelectedLabels.add(id);
- else createSelectedLabels.delete(id);
+ labelsEl.innerHTML = '
no labels
';
+ } else {
+ labelsEl.innerHTML = labels.map(l => `
+
+ `).join('');
+ labelsEl.querySelectorAll('.gitea-label-checkbox input').forEach(cb => {
+ cb.addEventListener('change', () => {
+ const id = parseInt(cb.closest('.gitea-label-checkbox').dataset.id);
+ if (cb.checked) createSelectedLabels.add(id);
+ else createSelectedLabels.delete(id);
+ });
});
- });
- } catch (err) {
- labelsEl.innerHTML = `
${escapeHtml(err.message)}
`;
+ }
+ } else {
+ const err = labelsRes.status === 'fulfilled' ? labelsRes.value.error : labelsRes.reason?.message;
+ labelsEl.innerHTML = `
${escapeHtml(err || 'failed')}
`;
+ }
+
+ // ── Milestones ──
+ if (milestonesRes.status === 'fulfilled' && milestonesRes.value.ok) {
+ const milestones = milestonesRes.value.milestones || [];
+ if (!milestones.length) {
+ milestoneSel.innerHTML = '
';
+ } else {
+ milestoneSel.innerHTML = '
' + milestones.map(ms =>
+ `
`
+ ).join('');
+ }
+ } else {
+ milestoneSel.innerHTML = '
';
+ }
+
+ // ── Collaborators (assignees) ──
+ if (collabsRes.status === 'fulfilled' && collabsRes.value.ok) {
+ const collabs = collabsRes.value.collaborators || [];
+ if (!collabs.length) {
+ assigneesEl.innerHTML = '
no collaborators
';
+ } else {
+ assigneesEl.innerHTML = collabs.map(u => `
+
+ `).join('');
+ assigneesEl.querySelectorAll('.gitea-label-checkbox input').forEach(cb => {
+ cb.addEventListener('change', () => {
+ const login = cb.closest('.gitea-label-checkbox').dataset.login;
+ if (cb.checked) createSelectedAssignees.add(login);
+ else createSelectedAssignees.delete(login);
+ });
+ });
+ }
+ } else {
+ const err = collabsRes.status === 'fulfilled' ? collabsRes.value.error : collabsRes.reason?.message;
+ assigneesEl.innerHTML = `
cannot list collaborators: ${escapeHtml(err || 'failed')}
`;
}
}
@@ -683,6 +997,9 @@ async function submitCreateIssue() {
try {
const payload = { title, body };
if (createSelectedLabels.size > 0) payload.labels = [...createSelectedLabels];
+ if (createSelectedAssignees.size > 0) payload.assignees = [...createSelectedAssignees];
+ const milestoneId = parseInt($('gitea-create-milestone').value);
+ if (milestoneId) payload.milestone = milestoneId;
const res = await hyprcrd.api(`api/gitea/repo/${currentAccountId}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, {
method: 'POST',
body: payload,
@@ -867,6 +1184,9 @@ async function openLabelsEditor() {
const editor = $('gitea-labels-editor');
editor.classList.remove('hidden');
const list = $('gitea-labels-checkboxes');
+ const warning = $('gitea-labels-warning');
+ warning.classList.add('hidden');
+ warning.textContent = '';
list.innerHTML = '
loading labels...
';
try {
@@ -876,6 +1196,10 @@ async function openLabelsEditor() {
list.innerHTML = `
${escapeHtml(data.error || 'failed to load')}
`;
return;
}
+ if (data.warning) {
+ warning.textContent = '⚠ ' + data.warning;
+ warning.classList.remove('hidden');
+ }
availableRepoLabels = data.labels || [];
// Match current selections by name (since the issue's labels list contains
// {name, color} not ids)
@@ -1193,8 +1517,24 @@ function init() {
$('gitea-create-cancel').addEventListener('click', closeCreateForm);
$('gitea-create-cancel-2').addEventListener('click', closeCreateForm);
$('gitea-create-submit').addEventListener('click', submitCreateIssue);
- // Auto-load labels after the user picks/types a repo (change fires on blur)
- $('gitea-create-repo').addEventListener('change', loadCreateRepoLabels);
+ // Repo input — custom autocomplete dropdown
+ const repoInput = $('gitea-create-repo');
+ repoInput.addEventListener('focus', showRepoDropdown);
+ repoInput.addEventListener('input', showRepoDropdown);
+ repoInput.addEventListener('blur', () => {
+ // Delay hide so option mousedown can register
+ setTimeout(hideRepoDropdown, 150);
+ });
+ repoInput.addEventListener('keydown', e => {
+ if (e.key === 'ArrowDown') { e.preventDefault(); moveRepoDropdownActive(1); }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); moveRepoDropdownActive(-1); }
+ else if (e.key === 'Enter') {
+ if (commitRepoDropdownActive()) { e.preventDefault(); }
+ }
+ else if (e.key === 'Escape') { hideRepoDropdown(); }
+ });
+ // Auto-load labels when the user leaves the field (no dropdown commit)
+ repoInput.addEventListener('change', loadCreateRepoLabels);
// Detail buttons
$('gitea-extract-btn').addEventListener('click', extractIssue);
@@ -1223,6 +1563,12 @@ function init() {
$('gitea-labels-save').addEventListener('click', saveLabels);
$('gitea-labels-cancel').addEventListener('click', closeLabelsEditor);
+ // Metadata editor (assignees + milestone + due date)
+ $('gitea-meta-edit-btn').addEventListener('click', openMetaEditor);
+ $('gitea-meta-save').addEventListener('click', saveMetaEditor);
+ $('gitea-meta-cancel').addEventListener('click', closeMetaEditor);
+ $('gitea-meta-due-clear').addEventListener('click', clearMetaDue);
+
// Comment composer
$('gitea-comment-post').addEventListener('click', postComment);
$('gitea-comment-input').addEventListener('keydown', e => {