/** * Git Board — Surface Entry Point * * Kanban board for Gitea issues and PRs. * Calls Starlark backend via /s/git-board/api/*. * Authentication via gitea-client library connections. * * Features: DnD between columns, issue detail modal with comments. */ (async function () { 'use strict'; var mount = document.getElementById('extension-mount'); if (!mount) return; var base = window.__BASE__ || ''; var ver = window.__VERSION__ || '0'; var slug = 'git-board'; // ── Boot SDK ─────────────────────────────── try { if (!window.preact) { var { h, render } = await import(base + '/js/sw/vendor/preact.module.js'); var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js'); var htmModule = await import(base + '/js/sw/vendor/htm.module.js'); window.preact = { h, render }; window.hooks = hooksModule; window.html = htmModule.default.bind(h); } var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver); await sdk.boot(); } catch (e) { mount.innerHTML = '

SDK boot failed: ' + e.message + '

'; return; } var { html } = window; var { useState, useEffect, useCallback, useRef, useMemo } = hooks; var { render } = preact; var API = base + '/s/' + slug + '/api'; async function api(path, opts) { var token = sw.auth._getToken(); var fetchOpts = { headers: { 'Authorization': 'Bearer ' + token } }; if (opts && opts.method) fetchOpts.method = opts.method; if (opts && opts.body) { fetchOpts.body = JSON.stringify(opts.body); fetchOpts.headers['Content-Type'] = 'application/json'; } var resp = await fetch(API + path, fetchOpts); var text = await resp.text(); try { return JSON.parse(text); } catch (_) { return { error: text }; } } // ── App ──────────────────────────────────── function App() { var [owner, setOwner] = useState(''); var [repo, setRepo] = useState(''); var [repos, setRepos] = useState([]); var [board, setBoard] = useState(null); var [loading, setLoading] = useState(false); var [needsConn, setNeedsConn] = useState(false); var [modal, setModal] = useState(null); // {owner, repo, number} var menuRef = useRef(null); useEffect(function () { if (menuRef.current && sw.userMenu) { sw.userMenu(menuRef.current, { placement: 'down-left' }); } }, []); useEffect(function () { api('/repos').then(function (d) { if (d.error) { if (d.error.indexOf('no gitea connection configured') !== -1) { setNeedsConn(true); } else { sw.toast(d.error, 'error'); } return; } setRepos(d.data || []); if (d.data && d.data.length > 0 && !owner) { setOwner(d.data[0].owner); setRepo(d.data[0].name); } }); }, []); var loadBoard = useCallback(function () { if (!owner || !repo) return; setLoading(true); api('/board?owner=' + encodeURIComponent(owner) + '&repo=' + encodeURIComponent(repo)) .then(function (d) { if (d.error) { sw.toast(d.error, 'error'); if (d.error.indexOf('no gitea connection configured') !== -1) setNeedsConn(true); } else { setBoard(d); setNeedsConn(false); } setLoading(false); }); }, [owner, repo]); useEffect(function () { loadBoard(); }, [loadBoard]); var onDrop = useCallback(function (issueNumber, targetCol) { if (!board) return; var issue = (board.issues || []).find(function (i) { return i.number === issueNumber; }); if (!issue) return; var patch = {}; if (targetCol === 'open') { // Move to Open: unassign + reopen if (issue.state === 'closed') patch.state = 'open'; // Note: Gitea PATCH /issues doesn't support clearing assignee via empty string, // but we do our best — the board will re-split on refresh. } else if (targetCol === 'in_progress') { // Move to In Progress: assign to current user + ensure open if (issue.state === 'closed') patch.state = 'open'; // Gitea needs assignees array — not supported by our simple update_issue. // For now, just reopen. User assigns via modal. if (issue.state === 'closed') patch.state = 'open'; } else if (targetCol === 'done') { patch.state = 'closed'; } if (!patch.state) return; // no meaningful change // Optimistic update var newIssues = (board.issues || []).map(function (i) { if (i.number !== issueNumber) return i; var copy = {}; for (var k in i) copy[k] = i[k]; if (patch.state) copy.state = patch.state; return copy; }); setBoard({ issues: newIssues, pull_requests: board.pull_requests || [] }); api('/issue/' + owner + '/' + repo + '/' + issueNumber, { method: 'POST', body: patch }).then(function (d) { if (d.error) { sw.toast('Update failed: ' + d.error, 'error'); loadBoard(); // revert } }); }, [board, owner, repo, loadBoard]); var openModal = useCallback(function (number) { setModal({ owner: owner, repo: repo, number: number }); }, [owner, repo]); var closeModal = useCallback(function (changed) { setModal(null); if (changed) loadBoard(); }, [loadBoard]); return html`
${needsConn ? html`<${ConnectionSetup} />` : html`

Git Board

<${RepoPicker} repos=${repos} owner=${owner} repo=${repo} onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
${loading ? 'Loading…' : 'Select a repository'}
`}
`} ${modal && html`<${IssueModal} ...${modal} onClose=${closeModal} />`} `; } // ── Connection Setup ───────────────────────── function ConnectionSetup() { return html`

Git Board

Connect to Gitea

Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.

Ask your admin to add a Gitea connection in Admin → Connections, or add a personal one in Settings → Connections.

Connections are managed centrally — no API tokens in extension settings.

`; } // ── Repo Picker ──────────────────────────── function RepoPicker({ repos, owner, repo, onSelect }) { var current = owner + '/' + repo; return html` `; } // ── Kanban Board with DnD ───────────────── function Board({ data, onDrop, onCardClick }) { var issues = data.issues || []; var prs = data.pull_requests || []; var openUnassigned = issues.filter(function (i) { return i.state === 'open' && !i.assignee; }); var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; }); return html`
<${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}> ${openUnassigned.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`; })} <${Column} id="in_progress" title="In Progress" count=${inProgress.length} onDrop=${onDrop}> ${inProgress.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`; })} <${Column} id="done" title="Done" count=${0} onDrop=${onDrop}> <${Column} id="prs" title="Pull Requests" count=${prs.length}> ${prs.map(function (p) { return html`<${PRCard} key=${p.number} pr=${p} />`; })}
`; } function Column({ id, title, count, children, onDrop }) { var [over, setOver] = useState(false); var handlers = onDrop ? { onDragOver: function (e) { e.preventDefault(); setOver(true); }, onDragLeave: function () { setOver(false); }, onDrop: function (e) { e.preventDefault(); setOver(false); var num = parseInt(e.dataTransfer.getData('text/plain'), 10); if (num && onDrop) onDrop(num, id); } } : {}; return html`
${title} ${count || 0}
${children}
`; } function IssueCard({ issue, onClick }) { return html`
#${issue.number} ${issue.assignee && html`@${esc(issue.assignee)}`}
${esc(issue.title)}
${(issue.labels || []).map(function (l) { return html`${esc(l)}`; })}
`; } function PRCard({ pr }) { return html`
#${pr.number} ${esc(pr.head)} → ${esc(pr.base)}
${esc(pr.title)}
@${esc(pr.user)} ${timeAgo(pr.created_at)}
`; } // ── Issue Detail Modal ──────────────────── function IssueModal({ owner, repo, number, onClose }) { var [issue, setIssue] = useState(null); var [loading, setLoading] = useState(true); var [comment, setComment] = useState(''); var [posting, setPosting] = useState(false); var [changed, setChanged] = useState(false); var bodyRef = useRef(null); useEffect(function () { setLoading(true); api('/issue/' + owner + '/' + repo + '/' + number).then(function (d) { if (d.error) { sw.toast(d.error, 'error'); onClose(false); return; } setIssue(d); setLoading(false); }); }, [owner, repo, number]); // Close on Escape useEffect(function () { var handler = function (e) { if (e.key === 'Escape') onClose(changed); }; document.addEventListener('keydown', handler); return function () { document.removeEventListener('keydown', handler); }; }, [changed]); var postComment = useCallback(function () { if (!comment.trim() || posting) return; setPosting(true); api('/issue/' + owner + '/' + repo + '/' + number + '/comment', { method: 'POST', body: { body: comment.trim() } }).then(function (d) { if (d.error) { sw.toast(d.error, 'error'); } else { // Append to local comments setIssue(function (prev) { if (!prev) return prev; var copy = {}; for (var k in prev) copy[k] = prev[k]; copy.comments = (prev.comments || []).concat([d]); return copy; }); setComment(''); setChanged(true); } setPosting(false); }); }, [comment, posting, owner, repo, number]); var toggleState = useCallback(function () { if (!issue) return; var newState = issue.state === 'open' ? 'closed' : 'open'; api('/issue/' + owner + '/' + repo + '/' + number, { method: 'POST', body: { state: newState } }).then(function (d) { if (d.error) { sw.toast(d.error, 'error'); return; } setIssue(function (prev) { if (!prev) return prev; var copy = {}; for (var k in prev) copy[k] = prev[k]; copy.state = newState; return copy; }); setChanged(true); }); }, [issue, owner, repo, number]); return html`
${loading ? 'Loading…' : html` #${number}

${esc(issue && issue.title)}

`}
${!loading && issue && html`
${issue.state} ${issue.assignee && html`@${esc(issue.assignee)}`} ${issue.created_at && html`${new Date(issue.created_at).toLocaleDateString()}`} Open in Gitea ↗
${issue.labels && issue.labels.length > 0 && html`
${issue.labels.map(function (l) { return html`${esc(l)}`; })}
`}
${issue.body ? html`
${esc(issue.body)}
` : html`

No description.

`}

Comments (${(issue.comments || []).length})

${(issue.comments || []).length === 0 && html`

No comments yet.

`} ${(issue.comments || []).map(function (c, i) { return html`
@${esc(c.user)} ${timeAgo(c.created_at)}
${esc(c.body)}
`; })}