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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"title": "Gitea API Client",
|
||||
"type": "library",
|
||||
"tier": "starlark",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "Shared Gitea client — connections, API helpers, optional data caching.",
|
||||
"author": "switchboard",
|
||||
|
||||
|
||||
@@ -15,9 +15,19 @@
|
||||
# json — encode/decode (universal)
|
||||
|
||||
load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch", "auth_headers", "resp")
|
||||
load("star/repos.star", "get_repos")
|
||||
load("star/issues.star", "get_issues", "get_issue", "create_issue", "update_issue", "add_comment")
|
||||
load("star/ci.star", "get_ci_status")
|
||||
load("star/repos.star", _repos_get = "get_repos")
|
||||
load("star/issues.star", _issues_get = "get_issues", _issue_get = "get_issue", _issue_create = "create_issue", _issue_update = "update_issue", _comment_add = "add_comment")
|
||||
load("star/ci.star", _ci_get = "get_ci_status")
|
||||
|
||||
# Re-export loaded functions so lib.require() can find them in globals.
|
||||
# Starlark load() names are file-local; explicit assignment makes them global.
|
||||
get_repos = _repos_get
|
||||
get_issues = _issues_get
|
||||
get_issue = _issue_get
|
||||
create_issue = _issue_create
|
||||
update_issue = _issue_update
|
||||
add_comment = _comment_add
|
||||
get_ci_status = _ci_get
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
@@ -111,12 +121,15 @@ def get_prs(conn, owner, repo, state):
|
||||
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", ""),
|
||||
"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", ""),
|
||||
"created_at": p.get("created_at", ""),
|
||||
"html_url": p.get("html_url", ""),
|
||||
"mergeable": p.get("mergeable", None),
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
@@ -13,11 +13,14 @@ def get_issues(conn, owner, repo, state):
|
||||
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", ""),
|
||||
"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", ""),
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
471
packages/sdk-test-runner/js/domains/git-board.js
Normal file
471
packages/sdk-test-runner/js/domains/git-board.js
Normal file
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* SDK Test Runner — Domain: git-board (v0.38.5)
|
||||
*
|
||||
* Tests the real-world library composition pattern:
|
||||
* gitea-client library (connection types, exports) → git-board consumer
|
||||
* (dependency, tools, delegation). Validates the full package lifecycle
|
||||
* with realistic manifests and Starlark scripts.
|
||||
* Requires admin role.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var T = window.SDKR;
|
||||
if (!T) return;
|
||||
|
||||
// ── Minimal zip builder ────────────────────────
|
||||
function buildZip(entries) {
|
||||
var localHeaders = [];
|
||||
var centralHeaders = [];
|
||||
var offset = 0;
|
||||
entries.forEach(function (entry) {
|
||||
var nameBytes = new TextEncoder().encode(entry.name);
|
||||
var dataBytes = new TextEncoder().encode(entry.content);
|
||||
var local = new Uint8Array(30 + nameBytes.length + dataBytes.length);
|
||||
var dv = new DataView(local.buffer);
|
||||
dv.setUint32(0, 0x04034b50, true);
|
||||
dv.setUint16(4, 20, true);
|
||||
dv.setUint16(8, 0, true);
|
||||
dv.setUint16(10, 0, true);
|
||||
dv.setUint16(12, 0, true);
|
||||
dv.setUint32(14, crc32(dataBytes), true);
|
||||
dv.setUint32(18, dataBytes.length, true);
|
||||
dv.setUint32(22, dataBytes.length, true);
|
||||
dv.setUint16(26, nameBytes.length, true);
|
||||
dv.setUint16(28, 0, true);
|
||||
local.set(nameBytes, 30);
|
||||
local.set(dataBytes, 30 + nameBytes.length);
|
||||
localHeaders.push(local);
|
||||
var central = new Uint8Array(46 + nameBytes.length);
|
||||
var cdv = new DataView(central.buffer);
|
||||
cdv.setUint32(0, 0x02014b50, true);
|
||||
cdv.setUint16(4, 20, true);
|
||||
cdv.setUint16(6, 20, true);
|
||||
cdv.setUint16(28, nameBytes.length, true);
|
||||
cdv.setUint32(16, crc32(dataBytes), true);
|
||||
cdv.setUint32(20, dataBytes.length, true);
|
||||
cdv.setUint32(24, dataBytes.length, true);
|
||||
cdv.setUint32(42, offset, true);
|
||||
central.set(nameBytes, 46);
|
||||
centralHeaders.push(central);
|
||||
offset += local.length;
|
||||
});
|
||||
var centralSize = centralHeaders.reduce(function (s, c) { return s + c.length; }, 0);
|
||||
var eocd = new Uint8Array(22);
|
||||
var edv = new DataView(eocd.buffer);
|
||||
edv.setUint32(0, 0x06054b50, true);
|
||||
edv.setUint16(8, entries.length, true);
|
||||
edv.setUint16(10, entries.length, true);
|
||||
edv.setUint32(12, centralSize, true);
|
||||
edv.setUint32(16, offset, true);
|
||||
var result = new Uint8Array(offset + centralSize + 22);
|
||||
var pos = 0;
|
||||
localHeaders.forEach(function (h) { result.set(h, pos); pos += h.length; });
|
||||
centralHeaders.forEach(function (h) { result.set(h, pos); pos += h.length; });
|
||||
result.set(eocd, pos);
|
||||
return new Blob([result], { type: 'application/zip' });
|
||||
}
|
||||
|
||||
var crcTable = null;
|
||||
function crc32(bytes) {
|
||||
if (!crcTable) {
|
||||
crcTable = new Uint32Array(256);
|
||||
for (var n = 0; n < 256; n++) {
|
||||
var c = n;
|
||||
for (var k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
crcTable[n] = c;
|
||||
}
|
||||
}
|
||||
var crc = 0xFFFFFFFF;
|
||||
for (var i = 0; i < bytes.length; i++) crc = crcTable[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
|
||||
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
|
||||
function makePkgFile(manifest, extraFiles) {
|
||||
var entries = [{ name: 'manifest.json', content: JSON.stringify(manifest) }];
|
||||
if (extraFiles) entries = entries.concat(extraFiles);
|
||||
var blob = buildZip(entries);
|
||||
return new File([blob], manifest.id + '.pkg', { type: 'application/zip' });
|
||||
}
|
||||
|
||||
// ── Starlark script content ────────────────────
|
||||
// Realistic but self-contained — no real HTTP calls needed.
|
||||
|
||||
var LIB_HTTP_HELPERS = [
|
||||
'def auth_headers(conn):',
|
||||
' token = conn.get("api_token", "")',
|
||||
' if not token:',
|
||||
' return {}',
|
||||
' return {"Authorization": "token " + token}',
|
||||
'',
|
||||
'def _base(conn):',
|
||||
' url = conn.get("base_url", "")',
|
||||
' if url.endswith("/"):',
|
||||
' url = url[:-1]',
|
||||
' return url',
|
||||
'',
|
||||
'def gitea_get(conn, path):',
|
||||
' url = _base(conn) + "/api/v1" + path',
|
||||
' resp = http.get(url=url, headers=auth_headers(conn))',
|
||||
' if int(resp["status"]) >= 400:',
|
||||
' return None',
|
||||
' body = resp.get("body", "")',
|
||||
' if not body:',
|
||||
' return None',
|
||||
' return json.decode(body)',
|
||||
'',
|
||||
'def gitea_post(conn, path, data):',
|
||||
' url = _base(conn) + "/api/v1" + path',
|
||||
' hdrs = dict(auth_headers(conn), **{"Content-Type": "application/json"})',
|
||||
' resp = http.post(url=url, body=json.encode(data), headers=hdrs)',
|
||||
' if int(resp["status"]) >= 400:',
|
||||
' return None',
|
||||
' body = resp.get("body", "")',
|
||||
' if not body:',
|
||||
' return None',
|
||||
' return json.decode(body)',
|
||||
'',
|
||||
'def gitea_patch(conn, path, data):',
|
||||
' url = _base(conn) + "/api/v1" + path',
|
||||
' hdrs = dict(auth_headers(conn), **{"Content-Type": "application/json", "X-HTTP-Method-Override": "PATCH"})',
|
||||
' resp = http.post(url=url, body=json.encode(data), headers=hdrs)',
|
||||
' if int(resp["status"]) >= 400:',
|
||||
' return None',
|
||||
' body = resp.get("body", "")',
|
||||
' if not body:',
|
||||
' return None',
|
||||
' return json.decode(body)',
|
||||
'',
|
||||
'def resp(status, data):',
|
||||
' return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}',
|
||||
].join('\n');
|
||||
|
||||
var LIB_REPOS = [
|
||||
'load("star/http_helpers.star", "gitea_get")',
|
||||
'',
|
||||
'def get_repos(conn):',
|
||||
' data = gitea_get(conn, "/repos/search?limit=50&sort=updated")',
|
||||
' if data == None:',
|
||||
' return None',
|
||||
' repos = data if type(data) == "list" else data.get("data", [])',
|
||||
' out = []',
|
||||
' for r in repos:',
|
||||
' out.append({',
|
||||
' "full_name": r.get("full_name", ""),',
|
||||
' "name": r.get("name", ""),',
|
||||
' "owner": r.get("owner", {}).get("login", ""),',
|
||||
' })',
|
||||
' return out',
|
||||
].join('\n');
|
||||
|
||||
var LIB_ISSUES = [
|
||||
'load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch")',
|
||||
'',
|
||||
'def get_issues(conn, owner, repo, state):',
|
||||
' return []',
|
||||
'',
|
||||
'def get_issue(conn, owner, repo, number):',
|
||||
' return {"number": number, "title": "stub", "body": "", "state": "open", "labels": [], "assignee": "", "created_at": "", "comments": []}',
|
||||
'',
|
||||
'def create_issue(conn, owner, repo, title, body, labels):',
|
||||
' return {"number": 0, "title": title, "html_url": ""}',
|
||||
'',
|
||||
'def update_issue(conn, owner, repo, number, title, body, state):',
|
||||
' return {"number": number, "state": state or "open", "title": title or "stub"}',
|
||||
'',
|
||||
'def add_comment(conn, owner, repo, number, body):',
|
||||
' return {"id": 0, "body": body}',
|
||||
].join('\n');
|
||||
|
||||
var LIB_CI = [
|
||||
'load("star/http_helpers.star", "gitea_get")',
|
||||
'',
|
||||
'def get_ci_status(conn, owner, repo, ref):',
|
||||
' return {"ref": ref, "state": "pending", "statuses": [], "count": 0}',
|
||||
].join('\n');
|
||||
|
||||
var LIB_SCRIPT = [
|
||||
'load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch", "auth_headers", "resp")',
|
||||
'load("star/repos.star", _repos_get = "get_repos")',
|
||||
'load("star/issues.star", _issues_get = "get_issues", _issue_get = "get_issue", _issue_create = "create_issue", _issue_update = "update_issue", _comment_add = "add_comment")',
|
||||
'load("star/ci.star", _ci_get = "get_ci_status")',
|
||||
'',
|
||||
'# Re-export for lib.require() visibility',
|
||||
'get_repos = _repos_get',
|
||||
'get_issues = _issues_get',
|
||||
'get_issue = _issue_get',
|
||||
'create_issue = _issue_create',
|
||||
'update_issue = _issue_update',
|
||||
'add_comment = _comment_add',
|
||||
'get_ci_status = _ci_get',
|
||||
'',
|
||||
'def get_prs(conn, owner, repo, state):',
|
||||
' return []',
|
||||
'',
|
||||
'def on_request(req):',
|
||||
' conn = connections.get("gitea")',
|
||||
' if conn == None:',
|
||||
' return resp(400, {"error": "no connection"})',
|
||||
' return resp(200, {"ok": True})',
|
||||
].join('\n');
|
||||
|
||||
var CONSUMER_SCRIPT = [
|
||||
'gitea = lib.require("{LIB_ID}")',
|
||||
'',
|
||||
'def _conn():',
|
||||
' return connections.get("gitea")',
|
||||
'',
|
||||
'def on_request(req):',
|
||||
' conn = _conn()',
|
||||
' if conn == None:',
|
||||
' return {"status": 400, "body": json.encode({"error": "no connection"}), "headers": {"Content-Type": "application/json"}}',
|
||||
' return {"status": 200, "body": json.encode({"ok": True}), "headers": {"Content-Type": "application/json"}}',
|
||||
'',
|
||||
'def on_tool_call(tool_name, params):',
|
||||
' conn = _conn()',
|
||||
' if conn == None:',
|
||||
' return {"error": "no connection"}',
|
||||
' if tool_name == "list_issues":',
|
||||
' data = gitea.get_issues(conn, params.get("owner",""), params.get("repo",""), "open")',
|
||||
' return {"issues": data or [], "count": len(data or [])}',
|
||||
' return {"error": "unknown tool"}',
|
||||
].join('\n');
|
||||
|
||||
// ── Tests ──────────────────────────────────────
|
||||
|
||||
T.registerDomain('git-board', async function () {
|
||||
|
||||
if (!window.sw || !sw.isAdmin) {
|
||||
await T.test('git-board', 'guard', 'skip — not admin', async function () {
|
||||
T.assert(true, 'skipped (user is not admin)');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var ts = Date.now();
|
||||
var libId = 'gb-test-lib-' + ts;
|
||||
var consumerId = 'gb-test-board-' + ts;
|
||||
var connType = 'gb-test-gitea-' + ts;
|
||||
var connId = null;
|
||||
|
||||
// ── 1. Install library with multi-file structure + connection type ──
|
||||
|
||||
await T.test('git-board', 'install-library', 'install gitea-client-style library with multi-file and connection type', async function () {
|
||||
var manifest = {
|
||||
id: libId,
|
||||
title: 'GB Test Gitea Client',
|
||||
type: 'library',
|
||||
tier: 'starlark',
|
||||
version: '1.0.1',
|
||||
exports: ['get_repos', 'get_issues', 'get_issue', 'create_issue', 'update_issue', 'add_comment', 'get_prs', 'get_ci_status'],
|
||||
permissions: ['api.http', 'connections.read', 'db.read', 'db.write'],
|
||||
connections: [{
|
||||
type: connType,
|
||||
label: 'Test Gitea Instance',
|
||||
fields: {
|
||||
base_url: { type: 'url', required: 'true', label: 'Server URL' },
|
||||
api_token: { type: 'secret', required: 'true', label: 'API Token' },
|
||||
org: { type: 'string', required: 'false', label: 'Default Org' }
|
||||
},
|
||||
scopes: ['global', 'team', 'personal']
|
||||
}],
|
||||
api_routes: [
|
||||
{ method: 'GET', path: '/repos' },
|
||||
{ method: 'GET', path: '/issues' }
|
||||
],
|
||||
db_tables: {
|
||||
repos: {
|
||||
columns: { full_name: 'text', owner: 'text', name: 'text' },
|
||||
indexes: []
|
||||
}
|
||||
},
|
||||
schema_version: 1
|
||||
};
|
||||
var file = makePkgFile(manifest, [
|
||||
{ name: 'script.star', content: LIB_SCRIPT },
|
||||
{ name: 'star/http_helpers.star', content: LIB_HTTP_HELPERS },
|
||||
{ name: 'star/repos.star', content: LIB_REPOS },
|
||||
{ name: 'star/issues.star', content: LIB_ISSUES },
|
||||
{ name: 'star/ci.star', content: LIB_CI }
|
||||
]);
|
||||
var r = await sw.api.admin.packages.install(file);
|
||||
T.assert(r && r.id === libId, 'library should install');
|
||||
T.assert(r.type === 'library', 'type should be library');
|
||||
});
|
||||
|
||||
T.cleanup.push(async function () {
|
||||
if (libId) try { await sw.api.admin.packages.del(libId); } catch (_) {}
|
||||
});
|
||||
|
||||
// ── 2. Grant library permissions ──
|
||||
|
||||
await T.test('git-board', 'grant-lib-perms', 'grant library permissions', async function () {
|
||||
await sw.api.post('/api/v1/admin/extensions/' + libId + '/permissions/grant-all', {});
|
||||
T.assert(true, 'permissions granted');
|
||||
});
|
||||
|
||||
// ── 3. Connection type appears ──
|
||||
|
||||
await T.test('git-board', 'conn-type-registered', 'connection type registered from library manifest', async function () {
|
||||
var types = await sw.api.connectionTypes.list();
|
||||
T.assert(Array.isArray(types), 'should be array');
|
||||
var found = types.find(function (t) { return t.type === connType; });
|
||||
T.assert(found, 'connection type should appear: ' + connType);
|
||||
T.assert(found.package_id === libId, 'package_id should be library');
|
||||
T.assert(found.fields && found.fields.api_token, 'should have api_token field');
|
||||
T.assert(found.fields.api_token.type === 'secret', 'api_token should be secret type');
|
||||
T.assert(found.fields.org, 'should have org field');
|
||||
});
|
||||
|
||||
// ── 4. Install consumer with dependency + tools ──
|
||||
|
||||
await T.test('git-board', 'install-consumer', 'install git-board-style consumer with dependency and tools', async function () {
|
||||
var manifest = {
|
||||
id: consumerId,
|
||||
title: 'GB Test Board',
|
||||
type: 'full',
|
||||
tier: 'starlark',
|
||||
route: '/s/' + consumerId,
|
||||
auth: 'authenticated',
|
||||
layout: 'single',
|
||||
version: '0.2.0',
|
||||
permissions: ['connections.read'],
|
||||
dependencies: {},
|
||||
api_routes: [
|
||||
{ method: 'GET', path: '/repos' },
|
||||
{ method: 'GET', path: '/board' },
|
||||
{ method: 'GET', path: '/ci/*' }
|
||||
],
|
||||
tools: [
|
||||
{ name: 'list_issues', description: 'List issues', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true } } },
|
||||
{ name: 'get_issue', description: 'Get issue', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true }, number: { type: 'number', required: true } } },
|
||||
{ name: 'create_issue', description: 'Create issue', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true }, title: { type: 'string', required: true } } },
|
||||
{ name: 'update_issue', description: 'Update issue', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true }, number: { type: 'number', required: true } } },
|
||||
{ name: 'add_comment', description: 'Add comment', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true }, number: { type: 'number', required: true }, body: { type: 'string', required: true } } },
|
||||
{ name: 'list_pull_requests', description: 'List PRs', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true } } },
|
||||
{ name: 'get_ci_status', description: 'Get CI status', parameters: { owner: { type: 'string', required: true }, repo: { type: 'string', required: true }, ref: { type: 'string', required: true } } }
|
||||
],
|
||||
settings: {
|
||||
default_owner: { type: 'string', label: 'Default Owner', default: '' },
|
||||
default_repo: { type: 'string', label: 'Default Repo', default: '' }
|
||||
}
|
||||
};
|
||||
manifest.dependencies[libId] = '>=1.0.0';
|
||||
var script = CONSUMER_SCRIPT.replace('{LIB_ID}', libId);
|
||||
var file = makePkgFile(manifest, [
|
||||
{ name: 'script.star', content: script }
|
||||
]);
|
||||
var r = await sw.api.admin.packages.install(file);
|
||||
T.assert(r && r.id === consumerId, 'consumer should install');
|
||||
T.assert(r.type === 'full', 'type should be full');
|
||||
});
|
||||
|
||||
T.cleanup.push(async function () {
|
||||
if (consumerId) try { await sw.api.admin.packages.del(consumerId); } catch (_) {}
|
||||
});
|
||||
|
||||
// ── 5. Grant consumer permissions ──
|
||||
|
||||
await T.test('git-board', 'grant-consumer-perms', 'grant consumer permissions', async function () {
|
||||
await sw.api.post('/api/v1/admin/extensions/' + consumerId + '/permissions/grant-all', {});
|
||||
T.assert(true, 'permissions granted');
|
||||
});
|
||||
|
||||
// ── 6. Dependency recorded ──
|
||||
|
||||
await T.test('git-board', 'dependency-recorded', 'consumer depends on library', async function () {
|
||||
var r = await sw.api.admin.packages.dependencies(consumerId);
|
||||
// SDK unwraps {data:[...]} to bare array
|
||||
var deps = Array.isArray(r) ? r : (r && r.data) || [];
|
||||
T.assert(deps.length > 0, 'should have dependencies');
|
||||
var dep = deps.find(function (d) { return d.library_id === libId; });
|
||||
T.assert(dep, 'dependency on library should exist');
|
||||
T.assert(dep.version_spec === '>=1.0.0', 'version spec should match');
|
||||
});
|
||||
|
||||
// ── 7. Tools registered ──
|
||||
|
||||
await T.test('git-board', 'tools-registered', 'consumer has 7 tools registered', async function () {
|
||||
var r = await sw.api.admin.packages.get(consumerId);
|
||||
T.assert(r, 'package should exist');
|
||||
var tools = r.tools || (r.manifest && r.manifest.tools) || [];
|
||||
T.assert(tools.length === 7, 'should have 7 tools, got ' + tools.length);
|
||||
var names = tools.map(function (t) { return t.name; }).sort();
|
||||
T.assert(names.indexOf('list_issues') >= 0, 'should include list_issues');
|
||||
T.assert(names.indexOf('get_ci_status') >= 0, 'should include get_ci_status');
|
||||
T.assert(names.indexOf('add_comment') >= 0, 'should include add_comment');
|
||||
});
|
||||
|
||||
// ── 8. Library consumers listed ──
|
||||
|
||||
await T.test('git-board', 'consumers-listed', 'library shows consumer in consumers list', async function () {
|
||||
var r = await sw.api.admin.packages.consumers(libId);
|
||||
// SDK unwraps {data:[...]} to bare array
|
||||
var consumers = Array.isArray(r) ? r : (r && r.data) || [];
|
||||
T.assert(consumers.length > 0, 'should have consumers');
|
||||
var found = consumers.find(function (c) { return c.consumer_id === consumerId; });
|
||||
T.assert(found, 'consumer should appear in library consumers list');
|
||||
});
|
||||
|
||||
// ── 9. Uninstall protection ──
|
||||
|
||||
await T.test('git-board', 'uninstall-protection', 'cannot uninstall library while consumer exists', async function () {
|
||||
var threw = false;
|
||||
try {
|
||||
await sw.api.admin.packages.del(libId);
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
var msg = (e && e.message) || String(e);
|
||||
T.assert(msg.indexOf('409') >= 0 || msg.indexOf('active') >= 0 || msg.indexOf('depend') >= 0,
|
||||
'should be 409 or dependency error: ' + msg);
|
||||
}
|
||||
T.assert(threw, 'delete should have thrown');
|
||||
});
|
||||
|
||||
// ── 10. Create connection of library type ──
|
||||
|
||||
await T.test('git-board', 'create-connection', 'create connection using library-declared type', async function () {
|
||||
var r = await sw.api.admin.connections.create({
|
||||
type: connType,
|
||||
package_id: libId,
|
||||
name: 'GB Test Connection',
|
||||
config: { base_url: 'https://test.example.com', api_token: 'test-token-gb', org: 'test-org' }
|
||||
});
|
||||
T.assert(r && r.id, 'should create connection');
|
||||
T.assert(r.type === connType, 'type should match');
|
||||
connId = r.id;
|
||||
});
|
||||
|
||||
T.cleanup.push(async function () {
|
||||
if (connId) try { await sw.api.admin.connections.del(connId); } catch (_) {}
|
||||
});
|
||||
|
||||
// ── Cleanup ──────────────────────────────────
|
||||
|
||||
await T.test('git-board', 'cleanup-connection', 'delete test connection', async function () {
|
||||
if (connId) {
|
||||
await sw.api.admin.connections.del(connId);
|
||||
connId = null;
|
||||
}
|
||||
T.assert(true, 'connection deleted');
|
||||
});
|
||||
|
||||
await T.test('git-board', 'cleanup-consumer', 'uninstall consumer', async function () {
|
||||
await sw.api.admin.packages.del(consumerId);
|
||||
consumerId = null;
|
||||
T.assert(true, 'consumer uninstalled');
|
||||
});
|
||||
|
||||
await T.test('git-board', 'cleanup-library', 'uninstall library', async function () {
|
||||
await sw.api.admin.packages.del(libId);
|
||||
libId = null;
|
||||
T.assert(true, 'library uninstalled');
|
||||
});
|
||||
|
||||
await T.test('git-board', 'types-removed', 'connection type removed after library uninstall', async function () {
|
||||
var types = await sw.api.connectionTypes.list();
|
||||
var found = types.find(function (t) { return t.type === connType; });
|
||||
T.assert(!found, 'type should not appear after library uninstall');
|
||||
});
|
||||
|
||||
});
|
||||
})();
|
||||
@@ -78,6 +78,7 @@
|
||||
'domains/connections.js',
|
||||
'domains/dependencies.js',
|
||||
'domains/composition.js',
|
||||
'domains/git-board.js',
|
||||
// UI (must come last)
|
||||
'ui.js'
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user