V0.38.5 git board rewrite (#238)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-25 22:24:59 +00:00
committed by xcaliber
parent 495bcc94f4
commit c03ece4230
24 changed files with 1354 additions and 413 deletions

View File

@@ -43,7 +43,7 @@
max-width: 260px;
}
/* ── Token Setup ─────────────────────────── */
/* ── Connection Setup ─────────────────────── */
.gb-setup {
max-width: 480px;
@@ -65,27 +65,6 @@
margin: 0 0 20px;
}
.gb-setup__form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.gb-setup__input {
flex: 1;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--mono);
font-size: 13px;
padding: 8px 10px;
outline: none;
}
.gb-setup__input:focus {
border-color: var(--accent);
}
.gb-setup__hint {
font-size: 12px;
@@ -229,6 +208,24 @@
color: var(--text-3);
}
/* ── DnD States ─────────────────────────── */
.gb-card[draggable="true"] {
cursor: grab;
user-select: none;
}
.gb-card[draggable="true"]:active {
cursor: grabbing;
opacity: 0.6;
}
.gb-column--dragover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 6%, var(--bg-surface));
}
.gb-column--dragover .gb-column__header {
border-bottom-color: var(--accent);
}
/* ── Empty state ─────────────────────────── */
.gb-empty {
@@ -239,3 +236,221 @@
color: var(--text-3);
font-size: 14px;
}
/* ── Issue Detail Modal ─────────────────── */
.gb-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 16px;
overflow-y: auto;
}
.gb-modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 100%;
max-width: 680px;
max-height: calc(100vh - 80px);
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.gb-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.gb-modal__title-row {
display: flex;
align-items: baseline;
gap: 8px;
flex: 1;
min-width: 0;
}
.gb-modal__title {
font-size: 18px;
font-weight: 600;
color: var(--text);
margin: 0;
word-break: break-word;
}
.gb-modal__close {
background: none;
border: none;
color: var(--text-3);
font-size: 18px;
cursor: pointer;
padding: 2px 6px;
border-radius: var(--radius);
flex-shrink: 0;
}
.gb-modal__close:hover {
color: var(--text);
background: var(--bg-hover);
}
.gb-modal__body {
overflow-y: auto;
padding: 16px 20px;
flex: 1;
min-height: 0;
}
.gb-modal__meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
font-size: 12px;
color: var(--text-2);
}
.gb-modal__date {
color: var(--text-3);
}
.gb-modal__extlink {
margin-left: auto;
color: var(--accent);
text-decoration: none;
font-size: 12px;
}
.gb-modal__extlink:hover {
text-decoration: underline;
}
.gb-modal__description {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.gb-modal__body-text {
font-family: var(--font);
font-size: 13px;
line-height: 1.6;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
margin: 0;
background: none;
border: none;
padding: 0;
}
.gb-modal__empty {
color: var(--text-3);
font-size: 13px;
font-style: italic;
margin: 0;
}
.gb-modal__section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-2);
text-transform: uppercase;
letter-spacing: 0.03em;
margin: 0 0 12px;
}
/* ── Comments ────────────────────────────── */
.gb-comment {
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.gb-comment:last-child {
border-bottom: none;
}
.gb-comment__header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 4px;
font-size: 12px;
}
.gb-comment__header strong {
color: var(--accent);
}
.gb-comment__date {
color: var(--text-3);
font-size: 11px;
}
.gb-comment__body {
font-size: 13px;
line-height: 1.5;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
}
/* ── Add Comment ─────────────────────────── */
.gb-modal__add-comment {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.gb-modal__textarea {
width: 100%;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 13px;
padding: 8px 10px;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.gb-modal__textarea:focus {
border-color: var(--accent);
}
.gb-modal__actions {
display: flex;
gap: 8px;
margin-top: 8px;
justify-content: flex-end;
}
/* ── Badge variants ──────────────────────── */
.badge--green {
background: var(--green);
color: #fff;
}
.badge--muted {
background: var(--bg-raised);
color: var(--text-3);
}
.btn-danger {
background: var(--danger, #e53e3e);
color: #fff;
border: none;
}
.btn-danger:hover {
opacity: 0.9;
}

View File

@@ -1,9 +1,11 @@
/**
* Git Board — Surface Entry Point
*
* Kanban board for Gitea/GitHub issues and PRs.
* Kanban board for Gitea issues and PRs.
* Calls Starlark backend via /s/git-board/api/*.
* Per-user API token via extension settings.
* Authentication via gitea-client library connections.
*
* Features: DnD between columns, issue detail modal with comments.
*/
(async function () {
'use strict';
@@ -33,16 +35,20 @@
}
var { html } = window;
var { useState, useEffect, useCallback, useRef } = hooks;
var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
var { render } = preact;
var API = base + '/s/' + slug + '/api';
async function api(path) {
async function api(path, opts) {
var token = sw.auth._getToken();
var resp = await fetch(API + path, {
headers: { 'Authorization': 'Bearer ' + token }
});
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 }; }
}
@@ -50,26 +56,27 @@
// ── App ────────────────────────────────────
function App() {
var [owner, setOwner] = useState('');
var [repo, setRepo] = useState('');
var [repos, setRepos] = useState([]);
var [board, setBoard] = useState(null);
var [owner, setOwner] = useState('');
var [repo, setRepo] = useState('');
var [repos, setRepos] = useState([]);
var [board, setBoard] = useState(null);
var [loading, setLoading] = useState(false);
var [needsToken, setNeedsToken] = useState(false);
var [needsConn, setNeedsConn] = useState(false);
var [modal, setModal] = useState(null); // {owner, repo, number}
var menuRef = useRef(null);
// Mount user menu
useEffect(function () {
if (menuRef.current && sw.userMenu) {
sw.userMenu(menuRef.current, { placement: 'down-right' });
sw.userMenu(menuRef.current, { placement: 'down-left' });
}
}, []);
// Load repos on mount
useEffect(function () {
api('/repos').then(function (d) {
if (d.error && d.error.indexOf('failed') !== -1) {
setNeedsToken(true);
if (d.error) {
if (d.error.indexOf('no gitea connection configured') !== -1) {
setNeedsConn(true);
} else { sw.toast(d.error, 'error'); }
return;
}
setRepos(d.data || []);
@@ -87,10 +94,10 @@
.then(function (d) {
if (d.error) {
sw.toast(d.error, 'error');
if (d.error.indexOf('failed') !== -1) setNeedsToken(true);
if (d.error.indexOf('no gitea connection configured') !== -1) setNeedsConn(true);
} else {
setBoard(d);
setNeedsToken(false);
setNeedsConn(false);
}
setLoading(false);
});
@@ -98,71 +105,102 @@
useEffect(function () { loadBoard(); }, [loadBoard]);
if (needsToken) return html`
<${TokenSetup} menuRef=${menuRef} />
`;
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`
<div class="gb-shell">
<header class="gb-header">
<div class="gb-header__left">
<h1 class="gb-title">Git Board</h1>
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
</div>
<div class="gb-header__right">
<button class="btn-small" onClick=${loadBoard} disabled=${loading}>
${loading ? '↻' : 'Refresh'}
</button>
<div ref=${menuRef}></div>
</div>
</header>
${board ? html`<${Board} data=${board} />` : html`
<div class="gb-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
`}
</div>
<div class="user-menu-container" ref=${menuRef}></div>
${needsConn ? html`<${ConnectionSetup} />` : html`
<div class="gb-shell">
<header class="gb-header">
<div class="gb-header__left">
<h1 class="gb-title">Git Board</h1>
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
</div>
<div class="gb-header__right">
<button class="btn-small" onClick=${loadBoard} disabled=${loading}>
${loading ? '↻' : 'Refresh'}
</button>
</div>
</header>
${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
<div class="gb-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
`}
</div>
`}
${modal && html`<${IssueModal} ...${modal} onClose=${closeModal} />`}
`;
}
// ── Token Setup ────────────────────────────
function TokenSetup({ menuRef }) {
var [token, setToken] = useState('');
var [saving, setSaving] = useState(false);
async function save() {
if (!token.trim()) return;
setSaving(true);
try {
await sw.api.extensions.updateUserSettings('git-board', { api_token: token.trim() });
sw.toast('Token saved — reloading', 'success');
setTimeout(function () { location.reload(); }, 500);
} catch (e) {
sw.toast(e.message || 'Failed to save', 'error');
setSaving(false);
}
}
// ── Connection Setup ─────────────────────────
function ConnectionSetup() {
return html`
<div class="gb-shell">
<header class="gb-header">
<div class="gb-header__left"><h1 class="gb-title">Git Board</h1></div>
<div class="gb-header__right"><div ref=${menuRef}></div></div>
</header>
<div class="gb-setup">
<h2>Connect Your Account</h2>
<p>Enter your personal access token for Gitea or GitHub.<br/>
The admin configures the platform URL. You provide your own token.</p>
<div class="gb-setup__form">
<input type="password" class="gb-setup__input"
placeholder="ghp_... or gitea token"
value=${token} onInput=${function (e) { setToken(e.target.value); }} />
<button class="btn-small btn-primary" onClick=${save} disabled=${saving || !token.trim()}>
${saving ? 'Saving…' : 'Save Token'}
</button>
</div>
<p class="gb-setup__hint">Token is stored in your personal extension settings — not shared with other users.</p>
<h2>Connect to Gitea</h2>
<p>Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.</p>
<p>Ask your admin to add a <strong>Gitea</strong> connection in
<strong>Admin → Connections</strong>, or add a personal one in
<strong>Settings → Connections</strong>.</p>
<button class="btn-small btn-primary"
onClick=${function () { window.location.href = base + '/settings'; }}>
Open Settings
</button>
<p class="gb-setup__hint">Connections are managed centrally — no API tokens in extension settings.</p>
</div>
</div>
`;
@@ -185,41 +223,52 @@
`;
}
// ── Kanban Board ───────────────────────────
// ── Kanban Board with DnD ─────────────────
var COLUMNS = [
{ key: 'open', title: 'Open', filter: function (i) { return i.state === 'open'; } },
{ key: 'in_progress', title: 'In Progress', filter: function (i) { return i.assignee && i.state === 'open'; } },
{ key: 'prs', title: 'Pull Requests', isPR: true },
{ key: 'closed', title: 'Done', filter: function (i) { return i.state === 'closed'; } },
];
function Board({ data }) {
function Board({ data, onDrop, onCardClick }) {
var issues = data.issues || [];
var prs = data.pull_requests || [];
// Split issues: unassigned open vs assigned open
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`
<div class="gb-board">
<${Column} title="Open" count=${openUnassigned.length}>
${openUnassigned.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })}
<${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} title="In Progress" count=${inProgress.length}>
${inProgress.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })}
<${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} title="Pull Requests" count=${prs.length}>
<${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} />`; })}
<//>
</div>
`;
}
function Column({ title, count, children }) {
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`
<div class="gb-column">
<div class="gb-column ${over ? 'gb-column--dragover' : ''}" ...${handlers}>
<div class="gb-column__header">
<span class="gb-column__title">${title}</span>
<span class="gb-column__count">${count || 0}</span>
@@ -229,9 +278,17 @@
`;
}
function IssueCard({ issue }) {
function IssueCard({ issue, onClick }) {
return html`
<a class="gb-card" href=${issue.html_url} target="_blank" rel="noopener">
<div class="gb-card" draggable="true"
onDragStart=${function (e) {
e.dataTransfer.setData('text/plain', String(issue.number));
e.dataTransfer.effectAllowed = 'move';
}}
onClick=${function (e) {
e.preventDefault();
if (onClick) onClick(issue.number);
}}>
<div class="gb-card__header">
<span class="gb-card__number">#${issue.number}</span>
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</span>`}
@@ -242,7 +299,7 @@
return html`<span key=${l} class="badge">${esc(l)}</span>`;
})}
</div>
</a>
</div>
`;
}
@@ -262,6 +319,153 @@
`;
}
// ── 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`
<div class="gb-modal-overlay" onClick=${function (e) {
if (e.target.classList.contains('gb-modal-overlay')) onClose(changed);
}}>
<div class="gb-modal">
<div class="gb-modal__header">
<div class="gb-modal__title-row">
${loading ? 'Loading…' : html`
<span class="gb-card__number">#${number}</span>
<h2 class="gb-modal__title">${esc(issue && issue.title)}</h2>
`}
</div>
<button class="gb-modal__close" onClick=${function () { onClose(changed); }}>✕</button>
</div>
${!loading && issue && html`
<div class="gb-modal__body" ref=${bodyRef}>
<div class="gb-modal__meta">
<span class="badge ${issue.state === 'open' ? 'badge--green' : 'badge--muted'}">${issue.state}</span>
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</span>`}
${issue.created_at && html`<span class="gb-modal__date">${new Date(issue.created_at).toLocaleDateString()}</span>`}
<a href=${issue.html_url || '#'} target="_blank" rel="noopener"
class="gb-modal__extlink">Open in Gitea ↗</a>
</div>
${issue.labels && issue.labels.length > 0 && html`
<div class="gb-card__labels" style="margin-bottom:12px;">
${issue.labels.map(function (l) { return html`<span key=${l} class="badge">${esc(l)}</span>`; })}
</div>
`}
<div class="gb-modal__description">
${issue.body ? html`<pre class="gb-modal__body-text">${esc(issue.body)}</pre>`
: html`<p class="gb-modal__empty">No description.</p>`}
</div>
<div class="gb-modal__comments">
<h3 class="gb-modal__section-title">Comments (${(issue.comments || []).length})</h3>
${(issue.comments || []).length === 0 && html`
<p class="gb-modal__empty">No comments yet.</p>
`}
${(issue.comments || []).map(function (c, i) {
return html`
<div class="gb-comment" key=${i}>
<div class="gb-comment__header">
<strong>@${esc(c.user)}</strong>
<span class="gb-comment__date">${timeAgo(c.created_at)}</span>
</div>
<div class="gb-comment__body">${esc(c.body)}</div>
</div>
`;
})}
</div>
<div class="gb-modal__add-comment">
<textarea class="gb-modal__textarea" rows="3"
placeholder="Add a comment…"
value=${comment}
onInput=${function (e) { setComment(e.target.value); }}
onKeyDown=${function (e) {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) postComment();
}} />
<div class="gb-modal__actions">
<button class="btn-small btn-primary" disabled=${posting || !comment.trim()}
onClick=${postComment}>
${posting ? 'Posting…' : 'Comment'}
</button>
<button class="btn-small ${issue.state === 'open' ? 'btn-danger' : 'btn-secondary'}"
onClick=${toggleState}>
${issue.state === 'open' ? 'Close Issue' : 'Reopen Issue'}
</button>
</div>
</div>
</div>
`}
</div>
</div>
`;
}
// ── Utilities ──────────────────────────────
function esc(s) {

View File

@@ -6,15 +6,21 @@
"route": "/s/git-board",
"auth": "authenticated",
"layout": "single",
"version": "0.1.0",
"description": "Gitea/GitHub issue and PR board with LLM tools for reading, editing, and commenting. Per-user API key configuration.",
"version": "0.2.0",
"description": "Gitea issue and PR board powered by the gitea-client library. Uses extension connections for authentication.",
"author": "switchboard",
"permissions": ["api.http"],
"permissions": ["connections.read"],
"dependencies": {
"gitea-client": ">=1.0.0"
},
"api_routes": [
{"method": "GET", "path": "/repos"},
{"method": "GET", "path": "/board"},
{"method": "GET", "path": "/issue/*"},
{"method": "POST", "path": "/issue/*"},
{"method": "GET", "path": "/ci/*"}
],
@@ -91,24 +97,6 @@
],
"settings": {
"platform": {
"type": "string",
"label": "Platform",
"description": "Git platform type: gitea or github",
"default": "gitea"
},
"base_url": {
"type": "string",
"label": "Base URL",
"description": "Git platform API base URL (e.g. https://git.gobha.me)",
"required": true
},
"api_token": {
"type": "string",
"label": "API Token (personal)",
"description": "Your personal access token. Set per-user in Settings > Extensions.",
"default": ""
},
"default_owner": {
"type": "string",
"label": "Default Owner/Org",

View File

@@ -1,13 +1,24 @@
# Git Board — Starlark Backend
# Git Board — Starlark Backend (v0.2.0)
#
# Library consumer — delegates all Gitea API work to gitea-client.
#
# Entry points:
# on_request(req) → surface API routes
# on_tool_call(tool_name, params) → LLM tool execution
#
# Modules:
# http — outbound HTTP to git platform (requires api.http permission)
# settings — admin: platform, base_url / user: api_token, default_owner, default_repo
# json — json.encode() / json.decode() (universal, no permission needed)
# connections — resolve gitea connection (connections.read)
# lib — load gitea-client library
# json — encode/decode (universal)
# settings — user: default_owner, default_repo
gitea = lib.require("gitea-client")
def _conn():
"""Resolve the first available gitea connection."""
return connections.get("gitea")
# ═══════════════════════════════════════════════
# Surface API routes (/s/git-board/api/*)
@@ -17,39 +28,33 @@ def on_request(req):
path = req["path"]
method = req["method"]
conn = _conn()
if conn == None:
return _resp(400, {"error": "no gitea connection configured — add one in Settings > Connections"})
if method == "GET" and path == "/repos":
return _handle_repos(req)
return _handle_repos(conn)
elif method == "GET" and path == "/board":
return _handle_board(req)
return _handle_board(conn, req)
elif method == "GET" and path.startswith("/issue/"):
return _handle_get_issue(conn, path[len("/issue/"):])
elif method == "POST" and path.startswith("/issue/"):
return _handle_post_issue(conn, path[len("/issue/"):], req)
elif method == "GET" and path.startswith("/ci/"):
return _handle_ci(req, path[len("/ci/"):])
return _handle_ci(conn, path[len("/ci/"):])
return _resp(404, {"error": "not found"})
def _handle_repos(req):
"""List repositories accessible to the user."""
platform = _platform()
data = _git_get(_repos_path(platform))
if data == None:
return _resp(502, {"error": "git platform request failed"})
# Normalize — Gitea returns list, GitHub returns {items: [...]}
repos = data if type(data) == "list" else data.get("items", data.get("repositories", []))
out = []
for r in repos:
out.append({
"full_name": r.get("full_name", ""),
"name": r.get("name", ""),
"owner": r.get("owner", {}).get("login", ""),
"description": r.get("description", ""),
"html_url": r.get("html_url", ""),
"open_issues": r.get("open_issues_count", 0),
})
return _resp(200, {"data": out})
def _handle_repos(conn):
"""List repositories accessible via the connection."""
repos = gitea.get_repos(conn)
if repos == None:
return _resp(502, {"error": "gitea request failed"})
return _resp(200, {"data": repos})
def _handle_board(req):
def _handle_board(conn, req):
"""Combined issues + PRs for kanban board."""
q = req.get("query", {})
owner = _str(q.get("owner", "")) or settings.get("default_owner") or ""
@@ -57,51 +62,64 @@ def _handle_board(req):
if not owner or not repo:
return _resp(400, {"error": "owner and repo required (query params or user settings)"})
platform = _platform()
issues = _git_get(_issues_path(platform, owner, repo, "open")) or []
prs = _git_get(_prs_path(platform, owner, repo, "open")) or []
board = {"issues": [], "pull_requests": []}
for i in issues:
labels = [l.get("name", "") for l in (i.get("labels") or [])]
board["issues"].append({
"number": i.get("number", 0),
"title": i.get("title", ""),
"state": i.get("state", ""),
"labels": labels,
"assignee": (i.get("assignee") or {}).get("login", ""),
"created_at": i.get("created_at", ""),
"updated_at": i.get("updated_at", ""),
"html_url": i.get("html_url", ""),
})
for p in prs:
board["pull_requests"].append({
"number": p.get("number", 0),
"title": p.get("title", ""),
"state": p.get("state", ""),
"head": p.get("head", {}).get("ref", ""),
"base": p.get("base", {}).get("ref", ""),
"mergeable": p.get("mergeable", None),
"user": (p.get("user") or {}).get("login", ""),
"created_at": p.get("created_at", ""),
"html_url": p.get("html_url", ""),
})
issues = gitea.get_issues(conn, owner, repo, "open") or []
prs = gitea.get_prs(conn, owner, repo, "open") or []
board = {"issues": issues, "pull_requests": prs}
return _resp(200, board)
def _handle_ci(req, ref_path):
def _handle_get_issue(conn, issue_path):
"""Get issue detail: /issue/:owner/:repo/:number"""
parts = issue_path.split("/", 2)
if len(parts) < 3:
return _resp(400, {"error": "path must be /issue/:owner/:repo/:number"})
owner, repo, num = parts[0], parts[1], int(parts[2])
data = gitea.get_issue(conn, owner, repo, num)
if data == None:
return _resp(404, {"error": "issue not found"})
return _resp(200, data)
def _handle_post_issue(conn, issue_path, req):
"""Update issue or add comment: /issue/:owner/:repo/:number[/comment]"""
parts = issue_path.split("/", 3)
if len(parts) < 3:
return _resp(400, {"error": "path must be /issue/:owner/:repo/:number"})
owner, repo, num = parts[0], parts[1], int(parts[2])
action = parts[3] if len(parts) > 3 else ""
body = json.decode(req.get("body", "{}"))
if action == "comment":
text = body.get("body", "")
if not text:
return _resp(400, {"error": "comment body required"})
result = gitea.add_comment(conn, owner, repo, num, text)
if result == None:
return _resp(502, {"error": "failed to add comment"})
return _resp(201, result)
# Default: update issue fields (state, title, body, assignee)
title = body.get("title", "")
issue_body = body.get("body", "")
state = body.get("state", "")
result = gitea.update_issue(conn, owner, repo, num, title, issue_body, state)
if result == None:
return _resp(502, {"error": "failed to update issue"})
return _resp(200, result)
def _handle_ci(conn, ref_path):
"""CI status for owner/repo/ref."""
parts = ref_path.split("/", 2)
if len(parts) < 3:
return _resp(400, {"error": "path must be /ci/:owner/:repo/:ref"})
owner, repo, ref = parts[0], parts[1], parts[2]
platform = _platform()
data = _git_get(_ci_path(platform, owner, repo, ref))
if data == None:
result = gitea.get_ci_status(conn, owner, repo, ref)
if result == None:
return _resp(502, {"error": "CI status request failed"})
return _resp(200, data)
return _resp(200, result)
# ═══════════════════════════════════════════════
@@ -109,211 +127,70 @@ def _handle_ci(req, ref_path):
# ═══════════════════════════════════════════════
def on_tool_call(tool_name, params):
platform = _platform()
conn = _conn()
if conn == None:
return {"error": "no gitea connection configured — add one in Settings > Connections"}
owner = params.get("owner", "")
repo = params.get("repo", "")
if tool_name == "list_issues":
state = params.get("state", "open")
data = _git_get(_issues_path(platform, owner, repo, state))
data = gitea.get_issues(conn, owner, repo, state)
if data == None:
return {"error": "failed to list issues"}
items = []
for i in data:
labels = [l.get("name", "") for l in (i.get("labels") or [])]
items.append({
"number": i.get("number", 0),
"title": i.get("title", ""),
"state": i.get("state", ""),
"labels": labels,
"assignee": (i.get("assignee") or {}).get("login", ""),
})
return {"issues": items, "count": len(items)}
return {"issues": data, "count": len(data)}
elif tool_name == "get_issue":
num = int(params.get("number", 0))
data = _git_get(_issue_path(platform, owner, repo, num))
data = gitea.get_issue(conn, owner, repo, num)
if data == None:
return {"error": "issue not found"}
# Also fetch comments
comments_data = _git_get(_issue_path(platform, owner, repo, num) + "/comments") or []
comments = []
for c in comments_data:
comments.append({
"user": (c.get("user") or {}).get("login", ""),
"body": c.get("body", ""),
"created_at": c.get("created_at", ""),
})
return {
"number": data.get("number", 0),
"title": data.get("title", ""),
"body": data.get("body", ""),
"state": data.get("state", ""),
"labels": [l.get("name", "") for l in (data.get("labels") or [])],
"assignee": (data.get("assignee") or {}).get("login", ""),
"created_at": data.get("created_at", ""),
"comments": comments,
}
return data
elif tool_name == "create_issue":
body = {"title": params.get("title", "")}
if params.get("body", ""):
body["body"] = params["body"]
if params.get("labels", ""):
body["labels"] = [l.strip() for l in params["labels"].split(",") if l.strip()]
data = _git_post(_issues_path(platform, owner, repo, ""), body)
data = gitea.create_issue(conn, owner, repo,
params.get("title", ""),
params.get("body", ""),
params.get("labels", ""))
if data == None:
return {"error": "failed to create issue"}
return {"number": data.get("number", 0), "title": data.get("title", ""), "html_url": data.get("html_url", "")}
return data
elif tool_name == "update_issue":
num = int(params.get("number", 0))
patch = {}
if params.get("title", ""):
patch["title"] = params["title"]
if params.get("body", ""):
patch["body"] = params["body"]
if params.get("state", ""):
patch["state"] = params["state"]
data = _git_patch(_issue_path(platform, owner, repo, num), patch)
data = gitea.update_issue(conn, owner, repo, num,
params.get("title", ""),
params.get("body", ""),
params.get("state", ""))
if data == None:
return {"error": "failed to update issue"}
return {"number": data.get("number", 0), "state": data.get("state", ""), "title": data.get("title", "")}
return data
elif tool_name == "add_comment":
num = int(params.get("number", 0))
data = _git_post(_issue_path(platform, owner, repo, num) + "/comments", {"body": params.get("body", "")})
data = gitea.add_comment(conn, owner, repo, num, params.get("body", ""))
if data == None:
return {"error": "failed to add comment"}
return {"id": data.get("id", 0), "body": data.get("body", "")}
return data
elif tool_name == "list_pull_requests":
state = params.get("state", "open")
data = _git_get(_prs_path(platform, owner, repo, state))
data = gitea.get_prs(conn, owner, repo, state)
if data == None:
return {"error": "failed to list PRs"}
items = []
for p in data:
items.append({
"number": p.get("number", 0),
"title": p.get("title", ""),
"state": p.get("state", ""),
"head": p.get("head", {}).get("ref", ""),
"base": p.get("base", {}).get("ref", ""),
"user": (p.get("user") or {}).get("login", ""),
})
return {"pull_requests": items, "count": len(items)}
return {"pull_requests": data, "count": len(data)}
elif tool_name == "get_ci_status":
ref = params.get("ref", "")
data = _git_get(_ci_path(platform, owner, repo, ref))
data = gitea.get_ci_status(conn, owner, repo, ref)
if data == None:
return {"error": "failed to get CI status"}
# Normalize: Gitea returns {statuses: [...]}, GitHub returns {state, statuses: [...]}
statuses = data.get("statuses", [])
items = []
for s in statuses:
items.append({
"context": s.get("context", s.get("name", "")),
"state": s.get("state", s.get("status", "")),
"description": s.get("description", ""),
"target_url": s.get("target_url", ""),
})
return {"ref": ref, "state": data.get("state", ""), "statuses": items, "count": len(items)}
return data
return {"error": "unknown tool: " + tool_name}
# ═══════════════════════════════════════════════
# Git platform abstraction
# ═══════════════════════════════════════════════
def _platform():
return settings.get("platform") or "gitea"
def _base_url():
url = settings.get("base_url") or ""
if url.endswith("/"):
url = url[:-1]
return url
def _token():
return settings.get("api_token") or ""
def _api_prefix(platform):
# Gitea: /api/v1, GitHub: "" (api.github.com already has /repos etc)
if platform == "github":
return ""
return "/api/v1"
def _repos_path(platform):
if platform == "github":
return "/user/repos?per_page=50&sort=updated"
return "/repos/search?limit=50&sort=updated"
def _issues_path(platform, owner, repo, state):
if platform == "github":
return "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&per_page=50"
return "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&limit=50&type=issues"
def _issue_path(platform, owner, repo, number):
return "/repos/" + owner + "/" + repo + "/issues/" + str(number)
def _prs_path(platform, owner, repo, state):
if platform == "github":
return "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&per_page=50"
return "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&limit=50"
def _ci_path(platform, owner, repo, ref):
if platform == "github":
return "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status"
return "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status"
# ═══════════════════════════════════════════════
# HTTP helpers
# ═══════════════════════════════════════════════
def _auth_headers():
token = _token()
if not token:
return {}
platform = _platform()
if platform == "github":
return {"Authorization": "Bearer " + token, "Accept": "application/vnd.github.v3+json"}
return {"Authorization": "token " + token}
def _git_get(path):
url = _base_url() + _api_prefix(_platform()) + path
resp = http.get(url=url, headers=_auth_headers())
if int(resp["status"]) >= 400:
return None
body = resp.get("body", "")
if not body:
return None
return json.decode(body)
def _git_post(path, data):
url = _base_url() + _api_prefix(_platform()) + path
resp = http.post(url=url, body=json.encode(data), headers=dict(_auth_headers(), **{"Content-Type": "application/json"}))
if int(resp["status"]) >= 400:
return None
body = resp.get("body", "")
if not body:
return None
return json.decode(body)
def _git_patch(path, data):
url = _base_url() + _api_prefix(_platform()) + path
resp = http.post(url=url, body=json.encode(data), headers=dict(_auth_headers(), **{"Content-Type": "application/json", "X-HTTP-Method-Override": "PATCH"}))
if int(resp["status"]) >= 400:
return None
body = resp.get("body", "")
if not body:
return None
return json.decode(body)
# ═══════════════════════════════════════════════
# Response helpers
# ═══════════════════════════════════════════════