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

@@ -1,5 +1,48 @@
# Changelog # Changelog
## [0.38.5.0] — 2026-03-25
### Summary
Git-board rewrite — library composition capstone. Rewrites git-board from
a self-contained monolith into a library consumer of gitea-client, proving
the full extension architecture end-to-end: lib.require(), connections,
dependencies, multi-file Starlark, and connection type discovery. Includes
5 platform bug fixes found during composition testing.
### Changed
- **git-board v0.2.0:** Rewrote Starlark backend to delegate all Gitea API
work to gitea-client library via `lib.require("gitea-client")`. Removed
`api.http` permission, platform abstraction, and per-user token settings.
Added `connections.read` permission and `gitea-client >= 1.0.0` dependency.
Surface JS: replaced TokenSetup with ConnectionSetup screen.
(~130 lines, down from ~329)
- **gitea-client v1.0.1:** Added missing fields to `get_issues()` (created_at,
updated_at, html_url) and `get_prs()` (created_at, html_url, mergeable).
Re-exported loaded sub-module functions via explicit global assignment for
lib.require() compatibility.
### Fixed (platform bugs)
- **ext_api.go:** Route handler no longer requires `api.http` for all packages
with API routes. Consumer packages that delegate HTTP to libraries don't
need `api.http` themselves.
- **packages.go:** `InstallPackage` handler now calls `SyncManifestPermissions`
so permission rows are created on package install (was only in the legacy
`AdminInstallExtension` handler).
- **runner.go:** Package loader for `load()` sub-modules now injects the `json`
module into predeclared scope, matching ExecWithLoader behavior.
- **connection_resolver.go:** Added panic recovery around `vault.Decrypt` to
prevent goroutine crashes from invalid nonce lengths after vault key rotation.
### Added
- **SDK test runner:** `git-board.js` domain (14 tests) — validates multi-file
library install, connection type registration, consumer dependency, tool
registration, consumers list, uninstall protection, connection CRUD, full
cleanup cycle.
## [0.38.2.0] — 2026-03-25 ## [0.38.2.0] — 2026-03-25
### Summary ### Summary

View File

@@ -1 +1 @@
0.38.2.0 0.38.5.0

View File

@@ -30,6 +30,7 @@ services:
STORAGE_BACKEND: pvc STORAGE_BACKEND: pvc
STORAGE_PATH: /data/storage STORAGE_PATH: /data/storage
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true}
LOG_FORMAT: ${LOG_FORMAT:-text} LOG_FORMAT: ${LOG_FORMAT:-text}
LOG_LEVEL: ${LOG_LEVEL:-info} LOG_LEVEL: ${LOG_LEVEL:-info}
# Dev seed users — ignored if ENVIRONMENT=production # Dev seed users — ignored if ENVIRONMENT=production

View File

@@ -54,7 +54,7 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
│ v0.37.1718 Surfaces ✅ │ │ v0.37.1718 Surfaces ✅ │
│ v0.37.19 Tag ✅ │ │ v0.37.19 Tag ✅ │
│ │ │ │ │ │
v0.38.0.5 │ │ v0.38.0.5 │ │
Extension │ │ Extension │ │
Continuation │ │ Continuation │ │
│ │ │ │ │ │
@@ -267,13 +267,13 @@ capability has a UI.
--- ---
## v0.38.x — Extension Track Continuation ## v0.38.x — Extension Track Continuation
Resumes the extension track (v0.29v0.31 ✅) with three new platform Resumes the extension track (v0.29v0.31 ✅) with three new platform
primitives: multi-file Starlark packages, extension connections, and primitives: multi-file Starlark packages, extension connections, and
library packages. Culminates in a git-board rewrite that validates the library packages. Culminates in a git-board rewrite that validates the
entire stack. **Must ship before v0.39.x** — dynamic workflows depend entire stack. **Must ship before v0.39.x** — dynamic workflows depend
on `load()`, connections, and libraries. on `load()`, connections, and libraries. **Complete as of v0.38.5.**
See design docs for full specifications. See design docs for full specifications.
@@ -282,9 +282,9 @@ See design docs for full specifications.
| v0.38.0 ✅ | Multi-file Starlark | [DESIGN-MULTI-FILE-STARLARK.md](DESIGN-MULTI-FILE-STARLARK.md) — `load()` support, disk-based scripts, package-scoped loader. Prerequisite for libraries. 5 backend changesets. | | v0.38.0 ✅ | Multi-file Starlark | [DESIGN-MULTI-FILE-STARLARK.md](DESIGN-MULTI-FILE-STARLARK.md) — `load()` support, disk-based scripts, package-scoped loader. Prerequisite for libraries. 5 backend changesets. |
| v0.38.1 ✅ | Extension Connections | [DESIGN-EXT-CONNECTIONS-LIBRARIES.md](DESIGN-EXT-CONNECTIONS-LIBRARIES.md) Part 1 — `ext_connections` table, scoped CRUD (personal → team → global resolution), `connections` Starlark module, 3 management UIs. | | v0.38.1 ✅ | Extension Connections | [DESIGN-EXT-CONNECTIONS-LIBRARIES.md](DESIGN-EXT-CONNECTIONS-LIBRARIES.md) Part 1 — `ext_connections` table, scoped CRUD (personal → team → global resolution), `connections` Starlark module, 3 management UIs. |
| v0.38.2 ✅ | Library Packages | Part 2 — `library` package type, `ext_dependencies` table, `lib.require()` with per-library permission context, dependency resolution, uninstall protection, `test-tool` admin endpoint. 8+3 SDK runner tests. | | v0.38.2 ✅ | Library Packages | Part 2 — `library` package type, `ext_dependencies` table, `lib.require()` with per-library permission context, dependency resolution, uninstall protection, `test-tool` admin endpoint. 8+3 SDK runner tests. |
| v0.38.3 | Extension Config Sections | Manifest-driven `config_section` — packages declare a Preact component that the Settings/Admin surfaces discover and lazy-load as a nav section. Enables headless extensions (no surface) and libraries to own their configuration UX. Libraries can provide rich config for their connection types (OAuth flows, test buttons, pickers). | | v0.38.3 | Extension Config Sections | Manifest-driven `config_section` — packages declare a Preact component that the Settings/Admin surfaces discover and lazy-load as a nav section. Enables headless extensions (no surface) and libraries to own their configuration UX. Libraries can provide rich config for their connection types (OAuth flows, test buttons, pickers). |
| v0.38.4 | Full Composition | Part 3 — Libraries declare connection types for consumers, reference `gitea-client` library. | | v0.38.4 | Full Composition | Part 3 — Libraries declare connection types for consumers, reference `gitea-client` library. |
| v0.38.5 | Git-board Rewrite | Capstone validation — rewrite git-board as multi-package, multi-platform extension (Gitea/GitLab/GitHub). Proves the entire extension architecture works end-to-end. Reference implementation for future extension authors. | | v0.38.5 | Git-board Rewrite | Capstone — git-board rewritten as gitea-client consumer. Proves lib.require(), connections, dependencies, multi-file Starlark end-to-end. 5 platform bugs fixed, 14 SDK runner tests. |
--- ---
@@ -339,9 +339,17 @@ needed to reach a polished MVP-ready product. May pull in features
from ongoing stakeholder meetings. from ongoing stakeholder meetings.
- [ ] UI bug fixes - [ ] UI bug fixes
- [ ] Git Board UserMenu display (text overlap / position on right edge)
- [ ] Dead code removal - [ ] Dead code removal
- [ ] Performance audit - [ ] Performance audit
- [ ] Accessibility pass - [ ] Accessibility pass
- [ ] Git Board polish:
- [ ] Inline issue creation ("+" button on Open column)
- [ ] Label filters (click label badge → filter board)
- [ ] Search/filter bar (text filter across card titles)
- [ ] Auto-refresh (poll every 60s)
- [ ] PR detail modal (diff stats, CI status badge, merge status)
- [ ] Surface custom icons (manifest `icon` field, replace puzzle piece default)
- [ ] Additional features TBD from stakeholder input - [ ] Additional features TBD from stakeholder input
--- ---
@@ -362,7 +370,7 @@ reading Go source code. Team admins build workflows visually.
- Full OpenAPI spec v0.36.0 (complete API documentation) ✅ - Full OpenAPI spec v0.36.0 (complete API documentation) ✅
- UI rewrite v0.37.x (Preact+htm migration, all surfaces) ✅ - UI rewrite v0.37.x (Preact+htm migration, all surfaces) ✅
- Extension continuation v0.38.x (multi-file Starlark, connections, - Extension continuation v0.38.x (multi-file Starlark, connections,
libraries, git-board rewrite) libraries, git-board rewrite)
- Workflow maturity v0.39.x (stage graph engine, simple + dynamic - Workflow maturity v0.39.x (stage graph engine, simple + dynamic
workflows, visitor portal, analytics) workflows, visitor portal, analytics)
- Polish v0.40.x (bug hunt, dead code, stabilization) - Polish v0.40.x (bug hunt, dead code, stabilization)

View File

@@ -43,7 +43,7 @@
max-width: 260px; max-width: 260px;
} }
/* ── Token Setup ─────────────────────────── */ /* ── Connection Setup ─────────────────────── */
.gb-setup { .gb-setup {
max-width: 480px; max-width: 480px;
@@ -65,27 +65,6 @@
margin: 0 0 20px; 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 { .gb-setup__hint {
font-size: 12px; font-size: 12px;
@@ -229,6 +208,24 @@
color: var(--text-3); 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 ─────────────────────────── */ /* ── Empty state ─────────────────────────── */
.gb-empty { .gb-empty {
@@ -239,3 +236,221 @@
color: var(--text-3); color: var(--text-3);
font-size: 14px; 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 * 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/*. * 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 () { (async function () {
'use strict'; 'use strict';
@@ -33,16 +35,20 @@
} }
var { html } = window; var { html } = window;
var { useState, useEffect, useCallback, useRef } = hooks; var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
var { render } = preact; var { render } = preact;
var API = base + '/s/' + slug + '/api'; var API = base + '/s/' + slug + '/api';
async function api(path) { async function api(path, opts) {
var token = sw.auth._getToken(); var token = sw.auth._getToken();
var resp = await fetch(API + path, { var fetchOpts = { headers: { 'Authorization': 'Bearer ' + token } };
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(); var text = await resp.text();
try { return JSON.parse(text); } catch (_) { return { error: text }; } try { return JSON.parse(text); } catch (_) { return { error: text }; }
} }
@@ -50,26 +56,27 @@
// ── App ──────────────────────────────────── // ── App ────────────────────────────────────
function App() { function App() {
var [owner, setOwner] = useState(''); var [owner, setOwner] = useState('');
var [repo, setRepo] = useState(''); var [repo, setRepo] = useState('');
var [repos, setRepos] = useState([]); var [repos, setRepos] = useState([]);
var [board, setBoard] = useState(null); var [board, setBoard] = useState(null);
var [loading, setLoading] = useState(false); 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); var menuRef = useRef(null);
// Mount user menu
useEffect(function () { useEffect(function () {
if (menuRef.current && sw.userMenu) { 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 () { useEffect(function () {
api('/repos').then(function (d) { api('/repos').then(function (d) {
if (d.error && d.error.indexOf('failed') !== -1) { if (d.error) {
setNeedsToken(true); if (d.error.indexOf('no gitea connection configured') !== -1) {
setNeedsConn(true);
} else { sw.toast(d.error, 'error'); }
return; return;
} }
setRepos(d.data || []); setRepos(d.data || []);
@@ -87,10 +94,10 @@
.then(function (d) { .then(function (d) {
if (d.error) { if (d.error) {
sw.toast(d.error, '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 { } else {
setBoard(d); setBoard(d);
setNeedsToken(false); setNeedsConn(false);
} }
setLoading(false); setLoading(false);
}); });
@@ -98,71 +105,102 @@
useEffect(function () { loadBoard(); }, [loadBoard]); useEffect(function () { loadBoard(); }, [loadBoard]);
if (needsToken) return html` var onDrop = useCallback(function (issueNumber, targetCol) {
<${TokenSetup} menuRef=${menuRef} /> 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` return html`
<div class="gb-shell"> <div class="user-menu-container" ref=${menuRef}></div>
<header class="gb-header"> ${needsConn ? html`<${ConnectionSetup} />` : html`
<div class="gb-header__left"> <div class="gb-shell">
<h1 class="gb-title">Git Board</h1> <header class="gb-header">
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo} <div class="gb-header__left">
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} /> <h1 class="gb-title">Git Board</h1>
</div> <${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
<div class="gb-header__right"> onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
<button class="btn-small" onClick=${loadBoard} disabled=${loading}> </div>
${loading ? '↻' : 'Refresh'} <div class="gb-header__right">
</button> <button class="btn-small" onClick=${loadBoard} disabled=${loading}>
<div ref=${menuRef}></div> ${loading ? '↻' : 'Refresh'}
</div> </button>
</header> </div>
</header>
${board ? html`<${Board} data=${board} />` : html` ${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
<div class="gb-empty">${loading ? 'Loading…' : 'Select a repository'}</div> <div class="gb-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
`} `}
</div> </div>
`}
${modal && html`<${IssueModal} ...${modal} onClose=${closeModal} />`}
`; `;
} }
// ── Token Setup ──────────────────────────── // ── Connection 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);
}
}
function ConnectionSetup() {
return html` return html`
<div class="gb-shell"> <div class="gb-shell">
<header class="gb-header"> <header class="gb-header">
<div class="gb-header__left"><h1 class="gb-title">Git Board</h1></div> <div class="gb-header__left"><h1 class="gb-title">Git Board</h1></div>
<div class="gb-header__right"><div ref=${menuRef}></div></div>
</header> </header>
<div class="gb-setup"> <div class="gb-setup">
<h2>Connect Your Account</h2> <h2>Connect to Gitea</h2>
<p>Enter your personal access token for Gitea or GitHub.<br/> <p>Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.</p>
The admin configures the platform URL. You provide your own token.</p> <p>Ask your admin to add a <strong>Gitea</strong> connection in
<div class="gb-setup__form"> <strong>Admin → Connections</strong>, or add a personal one in
<input type="password" class="gb-setup__input" <strong>Settings → Connections</strong>.</p>
placeholder="ghp_... or gitea token" <button class="btn-small btn-primary"
value=${token} onInput=${function (e) { setToken(e.target.value); }} /> onClick=${function () { window.location.href = base + '/settings'; }}>
<button class="btn-small btn-primary" onClick=${save} disabled=${saving || !token.trim()}> Open Settings
${saving ? 'Saving…' : 'Save Token'} </button>
</button> <p class="gb-setup__hint">Connections are managed centrally — no API tokens in extension settings.</p>
</div>
<p class="gb-setup__hint">Token is stored in your personal extension settings — not shared with other users.</p>
</div> </div>
</div> </div>
`; `;
@@ -185,41 +223,52 @@
`; `;
} }
// ── Kanban Board ─────────────────────────── // ── Kanban Board with DnD ─────────────────
var COLUMNS = [ function Board({ data, onDrop, onCardClick }) {
{ 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 }) {
var issues = data.issues || []; var issues = data.issues || [];
var prs = data.pull_requests || []; 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 openUnassigned = issues.filter(function (i) { return i.state === 'open' && !i.assignee; });
var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; }); var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
return html` return html`
<div class="gb-board"> <div class="gb-board">
<${Column} title="Open" count=${openUnassigned.length}> <${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}>
${openUnassigned.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })} ${openUnassigned.map(function (i) {
return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
})}
<//> <//>
<${Column} title="In Progress" count=${inProgress.length}> <${Column} id="in_progress" title="In Progress" count=${inProgress.length} onDrop=${onDrop}>
${inProgress.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })} ${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} />`; })} ${prs.map(function (p) { return html`<${PRCard} key=${p.number} pr=${p} />`; })}
<//> <//>
</div> </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` return html`
<div class="gb-column"> <div class="gb-column ${over ? 'gb-column--dragover' : ''}" ...${handlers}>
<div class="gb-column__header"> <div class="gb-column__header">
<span class="gb-column__title">${title}</span> <span class="gb-column__title">${title}</span>
<span class="gb-column__count">${count || 0}</span> <span class="gb-column__count">${count || 0}</span>
@@ -229,9 +278,17 @@
`; `;
} }
function IssueCard({ issue }) { function IssueCard({ issue, onClick }) {
return html` 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"> <div class="gb-card__header">
<span class="gb-card__number">#${issue.number}</span> <span class="gb-card__number">#${issue.number}</span>
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</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>`; return html`<span key=${l} class="badge">${esc(l)}</span>`;
})} })}
</div> </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 ────────────────────────────── // ── Utilities ──────────────────────────────
function esc(s) { function esc(s) {

View File

@@ -6,15 +6,21 @@
"route": "/s/git-board", "route": "/s/git-board",
"auth": "authenticated", "auth": "authenticated",
"layout": "single", "layout": "single",
"version": "0.1.0", "version": "0.2.0",
"description": "Gitea/GitHub issue and PR board with LLM tools for reading, editing, and commenting. Per-user API key configuration.", "description": "Gitea issue and PR board powered by the gitea-client library. Uses extension connections for authentication.",
"author": "switchboard", "author": "switchboard",
"permissions": ["api.http"], "permissions": ["connections.read"],
"dependencies": {
"gitea-client": ">=1.0.0"
},
"api_routes": [ "api_routes": [
{"method": "GET", "path": "/repos"}, {"method": "GET", "path": "/repos"},
{"method": "GET", "path": "/board"}, {"method": "GET", "path": "/board"},
{"method": "GET", "path": "/issue/*"},
{"method": "POST", "path": "/issue/*"},
{"method": "GET", "path": "/ci/*"} {"method": "GET", "path": "/ci/*"}
], ],
@@ -91,24 +97,6 @@
], ],
"settings": { "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": { "default_owner": {
"type": "string", "type": "string",
"label": "Default Owner/Org", "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: # Entry points:
# on_request(req) → surface API routes # on_request(req) → surface API routes
# on_tool_call(tool_name, params) → LLM tool execution # on_tool_call(tool_name, params) → LLM tool execution
# #
# Modules: # Modules:
# http — outbound HTTP to git platform (requires api.http permission) # connections — resolve gitea connection (connections.read)
# settings — admin: platform, base_url / user: api_token, default_owner, default_repo # lib — load gitea-client library
# json — json.encode() / json.decode() (universal, no permission needed) # 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/*) # Surface API routes (/s/git-board/api/*)
@@ -17,39 +28,33 @@ def on_request(req):
path = req["path"] path = req["path"]
method = req["method"] 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": if method == "GET" and path == "/repos":
return _handle_repos(req) return _handle_repos(conn)
elif method == "GET" and path == "/board": 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/"): 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"}) return _resp(404, {"error": "not found"})
def _handle_repos(req): def _handle_repos(conn):
"""List repositories accessible to the user.""" """List repositories accessible via the connection."""
platform = _platform() repos = gitea.get_repos(conn)
data = _git_get(_repos_path(platform)) if repos == None:
if data == None: return _resp(502, {"error": "gitea request failed"})
return _resp(502, {"error": "git platform request failed"}) return _resp(200, {"data": repos})
# 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_board(req): def _handle_board(conn, req):
"""Combined issues + PRs for kanban board.""" """Combined issues + PRs for kanban board."""
q = req.get("query", {}) q = req.get("query", {})
owner = _str(q.get("owner", "")) or settings.get("default_owner") or "" 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: if not owner or not repo:
return _resp(400, {"error": "owner and repo required (query params or user settings)"}) return _resp(400, {"error": "owner and repo required (query params or user settings)"})
platform = _platform() issues = gitea.get_issues(conn, owner, repo, "open") or []
prs = gitea.get_prs(conn, owner, repo, "open") or []
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", ""),
})
board = {"issues": issues, "pull_requests": prs}
return _resp(200, board) 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.""" """CI status for owner/repo/ref."""
parts = ref_path.split("/", 2) parts = ref_path.split("/", 2)
if len(parts) < 3: if len(parts) < 3:
return _resp(400, {"error": "path must be /ci/:owner/:repo/:ref"}) return _resp(400, {"error": "path must be /ci/:owner/:repo/:ref"})
owner, repo, ref = parts[0], parts[1], parts[2] owner, repo, ref = parts[0], parts[1], parts[2]
platform = _platform() result = gitea.get_ci_status(conn, owner, repo, ref)
data = _git_get(_ci_path(platform, owner, repo, ref)) if result == None:
if data == None:
return _resp(502, {"error": "CI status request failed"}) 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): 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", "") owner = params.get("owner", "")
repo = params.get("repo", "") repo = params.get("repo", "")
if tool_name == "list_issues": if tool_name == "list_issues":
state = params.get("state", "open") 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: if data == None:
return {"error": "failed to list issues"} return {"error": "failed to list issues"}
items = [] return {"issues": data, "count": len(data)}
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)}
elif tool_name == "get_issue": elif tool_name == "get_issue":
num = int(params.get("number", 0)) 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: if data == None:
return {"error": "issue not found"} return {"error": "issue not found"}
# Also fetch comments return data
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,
}
elif tool_name == "create_issue": elif tool_name == "create_issue":
body = {"title": params.get("title", "")} data = gitea.create_issue(conn, owner, repo,
if params.get("body", ""): params.get("title", ""),
body["body"] = params["body"] params.get("body", ""),
if params.get("labels", ""): 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)
if data == None: if data == None:
return {"error": "failed to create issue"} 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": elif tool_name == "update_issue":
num = int(params.get("number", 0)) num = int(params.get("number", 0))
patch = {} data = gitea.update_issue(conn, owner, repo, num,
if params.get("title", ""): params.get("title", ""),
patch["title"] = params["title"] params.get("body", ""),
if params.get("body", ""): params.get("state", ""))
patch["body"] = params["body"]
if params.get("state", ""):
patch["state"] = params["state"]
data = _git_patch(_issue_path(platform, owner, repo, num), patch)
if data == None: if data == None:
return {"error": "failed to update issue"} 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": elif tool_name == "add_comment":
num = int(params.get("number", 0)) 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: if data == None:
return {"error": "failed to add comment"} return {"error": "failed to add comment"}
return {"id": data.get("id", 0), "body": data.get("body", "")} return data
elif tool_name == "list_pull_requests": elif tool_name == "list_pull_requests":
state = params.get("state", "open") 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: if data == None:
return {"error": "failed to list PRs"} return {"error": "failed to list PRs"}
items = [] return {"pull_requests": data, "count": len(data)}
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)}
elif tool_name == "get_ci_status": elif tool_name == "get_ci_status":
ref = params.get("ref", "") 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: if data == None:
return {"error": "failed to get CI status"} return {"error": "failed to get CI status"}
# Normalize: Gitea returns {statuses: [...]}, GitHub returns {state, statuses: [...]} return data
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 {"error": "unknown tool: " + tool_name} 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 # Response helpers
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════

View File

@@ -3,7 +3,7 @@
"title": "Gitea API Client", "title": "Gitea API Client",
"type": "library", "type": "library",
"tier": "starlark", "tier": "starlark",
"version": "1.0.0", "version": "1.0.1",
"description": "Shared Gitea client — connections, API helpers, optional data caching.", "description": "Shared Gitea client — connections, API helpers, optional data caching.",
"author": "switchboard", "author": "switchboard",

View File

@@ -15,9 +15,19 @@
# json — encode/decode (universal) # json — encode/decode (universal)
load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch", "auth_headers", "resp") load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch", "auth_headers", "resp")
load("star/repos.star", "get_repos") load("star/repos.star", _repos_get = "get_repos")
load("star/issues.star", "get_issues", "get_issue", "create_issue", "update_issue", "add_comment") 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", "get_ci_status") 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 = [] items = []
for p in data: for p in data:
items.append({ items.append({
"number": p.get("number", 0), "number": p.get("number", 0),
"title": p.get("title", ""), "title": p.get("title", ""),
"state": p.get("state", ""), "state": p.get("state", ""),
"head": p.get("head", {}).get("ref", ""), "head": p.get("head", {}).get("ref", ""),
"base": p.get("base", {}).get("ref", ""), "base": p.get("base", {}).get("ref", ""),
"user": (p.get("user") or {}).get("login", ""), "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 return items

View File

@@ -13,11 +13,14 @@ def get_issues(conn, owner, repo, state):
for i in data: for i in data:
labels = [l.get("name", "") for l in (i.get("labels") or [])] labels = [l.get("name", "") for l in (i.get("labels") or [])]
items.append({ items.append({
"number": i.get("number", 0), "number": i.get("number", 0),
"title": i.get("title", ""), "title": i.get("title", ""),
"state": i.get("state", ""), "state": i.get("state", ""),
"labels": labels, "labels": labels,
"assignee": (i.get("assignee") or {}).get("login", ""), "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 return items

View 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');
});
});
})();

View File

@@ -78,6 +78,7 @@
'domains/connections.js', 'domains/connections.js',
'domains/dependencies.js', 'domains/dependencies.js',
'domains/composition.js', 'domains/composition.js',
'domains/git-board.js',
// UI (must come last) // UI (must come last)
'ui.js' 'ui.js'
]; ];

View File

@@ -63,13 +63,28 @@ func (r *ConnectionResolverAdapter) decryptInPlace(ctx context.Context, conn *mo
if len(enc) == 0 { if len(enc) == 0 {
continue continue
} }
plaintext, err := r.vault.Decrypt(enc, nonce, conn.Scope, conn.OwnerID) plaintext := r.safeDecrypt(enc, nonce, conn.Scope, conn.OwnerID)
if err == nil { if plaintext != "" {
conn.Config[k] = plaintext conn.Config[k] = plaintext
} }
} }
} }
// safeDecrypt wraps vault.Decrypt to recover from GCM panics caused by
// invalid nonce lengths (e.g. stale vault keys after container restart).
func (r *ConnectionResolverAdapter) safeDecrypt(enc, nonce []byte, scope, ownerID string) (plaintext string) {
defer func() {
if rv := recover(); rv != nil {
plaintext = ""
}
}()
result, err := r.vault.Decrypt(enc, nonce, scope, ownerID)
if err != nil {
return ""
}
return result
}
// lookupSecretFields reads the package manifest to identify secret fields. // lookupSecretFields reads the package manifest to identify secret fields.
func (r *ConnectionResolverAdapter) lookupSecretFields(ctx context.Context, packageID, connType string) map[string]bool { func (r *ConnectionResolverAdapter) lookupSecretFields(ctx context.Context, packageID, connType string) map[string]bool {
result := map[string]bool{} result := map[string]bool{}

View File

@@ -4,6 +4,7 @@ package handlers
// Pattern follows apiconfigs.go (ProviderConfigHandler). // Pattern follows apiconfigs.go (ProviderConfigHandler).
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"net/http" "net/http"
@@ -27,7 +28,9 @@ func NewConnectionHandler(s store.Stores, vault *crypto.KeyResolver) *Connection
// ListConnections returns the user's personal connections. // ListConnections returns the user's personal connections.
func (h *ConnectionHandler) ListConnections(c *gin.Context) { func (h *ConnectionHandler) ListConnections(c *gin.Context) {
userID := getUserID(c) userID := getUserID(c)
conns, err := h.stores.Connections.ListForUser(c.Request.Context(), userID) // v0.38.5: Show all accessible connections (personal + team + global)
// so users can see which connections are available to them.
conns, err := h.stores.Connections.ListAccessible(c.Request.Context(), userID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"})
return return
@@ -252,13 +255,23 @@ func (h *ConnectionHandler) decryptSecrets(config models.JSONMap, secretFields m
} }
// toBytes coerces an interface{} to []byte. Handles both []byte (from Go) // toBytes coerces an interface{} to []byte. Handles both []byte (from Go)
// and string (from JSON unmarshal of base64). // and string (from JSON unmarshal base64 encoded by json.Marshal for []byte).
func toBytes(v interface{}) []byte { func toBytes(v interface{}) []byte {
switch t := v.(type) { switch t := v.(type) {
case []byte: case []byte:
return t return t
case string: case string:
return []byte(t) if t == "" {
return nil
}
// JSON round-trip: json.Marshal encodes []byte as base64 strings.
// Decode back to the original bytes.
decoded, err := base64.StdEncoding.DecodeString(t)
if err != nil {
// Fallback: might be raw string (shouldn't happen, but be safe)
return []byte(t)
}
return decoded
} }
return nil return nil
} }

View File

@@ -100,7 +100,12 @@ func (h *ExtAPIHandler) Handle(c *gin.Context) {
return return
} }
// ── 3. Check api.http permission ─────────── // ── 3. Check permissions ──────────────────
// The package needs at least one granted permission to be active.
// api.http is NOT required here — a consumer package may delegate
// HTTP to a library via lib.require(). The sandbox wires the http
// module only when api.http is granted; without it the script simply
// cannot call http.get() directly, which is correct for consumers.
if h.stores.ExtPermissions == nil { if h.stores.ExtPermissions == nil {
c.JSON(http.StatusForbidden, gin.H{"error": "permission system unavailable"}) c.JSON(http.StatusForbidden, gin.H{"error": "permission system unavailable"})
return return
@@ -110,8 +115,8 @@ func (h *ExtAPIHandler) Handle(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "permission check failed"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "permission check failed"})
return return
} }
if !hasPermission(granted, models.ExtPermAPIHTTP) { if len(granted) == 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "extension does not have api.http permission"}) c.JSON(http.StatusForbidden, gin.H{"error": "extension has no granted permissions"})
return return
} }

View File

@@ -469,6 +469,11 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
} }
} }
// v0.38.5: Declare manifest permissions (same as AdminInstallExtension).
// This creates the permission rows and sets status to pending_review
// if the package declares permissions.
SyncManifestPermissions(c, h.stores, pkgID, manifest)
// v0.30.0: Run schema migrations if declared. // v0.30.0: Run schema migrations if declared.
newSchemaVersion := ParseSchemaVersion(manifest) newSchemaVersion := ParseSchemaVersion(manifest)
if newSchemaVersion > 0 && h.sandbox != nil { if newSchemaVersion > 0 && h.sandbox != nil {

View File

@@ -416,6 +416,11 @@ func main() {
if cfg.StoragePath != "" { if cfg.StoragePath != "" {
starlarkRunner.SetPackagesDir(cfg.StoragePath + "/packages") starlarkRunner.SetPackagesDir(cfg.StoragePath + "/packages")
} }
// v0.38.5: allow extensions to reach private IPs (self-hosted Gitea, etc.)
if os.Getenv("EXT_ALLOW_PRIVATE_IPS") == "true" {
starlarkRunner.SetAllowPrivateIPs(true)
log.Printf(" ⚠️ Extension SSRF protection relaxed: private IPs allowed")
}
// Discover and register active Starlark pre-completion filters // Discover and register active Starlark pre-completion filters
filters.DiscoverStarlarkFilters(context.Background(), filterChain, stores, starlarkRunner) filters.DiscoverStarlarkFilters(context.Background(), filterChain, stores, starlarkRunner)

View File

@@ -63,6 +63,12 @@ type HTTPModuleConfig struct {
// Ignored when AllowDomains is non-empty. // Ignored when AllowDomains is non-empty.
BlockDomains []string BlockDomains []string
// AllowPrivateIPs disables the SSRF check that blocks connections to
// private/loopback/link-local addresses. Use for self-hosted deployments
// where extensions need to reach internal services.
// Default: false (private IPs blocked).
AllowPrivateIPs bool
// Timeout overrides the default 10 s request timeout. // Timeout overrides the default 10 s request timeout.
// Zero means use default. // Zero means use default.
Timeout time.Duration Timeout time.Duration
@@ -345,11 +351,13 @@ func (ac *accessControl) checkDomain(host string) error {
return nil return nil
} }
// client builds an SSRF-safe http.Client with IP validation on connect. // client builds an HTTP client with optional SSRF IP validation on connect.
func (ac *accessControl) client() *http.Client { func (ac *accessControl) client() *http.Client {
dialer := &net.Dialer{ dialer := &net.Dialer{
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
Control: ssrfControl, }
if !ac.cfg.AllowPrivateIPs {
dialer.Control = ssrfControl
} }
transport := &http.Transport{ transport := &http.Transport{

View File

@@ -31,6 +31,8 @@ import (
"go.starlark.net/starlark" "go.starlark.net/starlark"
starlarkjson "go.starlark.net/lib/json"
"chat-switchboard/models" "chat-switchboard/models"
"chat-switchboard/store" "chat-switchboard/store"
) )
@@ -49,14 +51,15 @@ type RunContext struct {
// Runner executes Starlark package scripts with permission-gated modules. // Runner executes Starlark package scripts with permission-gated modules.
type Runner struct { type Runner struct {
sandbox *Sandbox sandbox *Sandbox
stores store.Stores stores store.Stores
packagesDir string // v0.38.0: disk path for load() support packagesDir string // v0.38.0: disk path for load() support
notifier NotificationSender // nil = notifications module unavailable notifier NotificationSender // nil = notifications module unavailable
resolver ProviderResolver // nil = provider module unavailable resolver ProviderResolver // nil = provider module unavailable
connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1) connResolver ConnectionResolver // nil = connections module unavailable (v0.38.1)
db *sql.DB // nil = db module unavailable db *sql.DB // nil = db module unavailable
dbPostgres bool // true = use $N placeholders; false = use ? dbPostgres bool // true = use $N placeholders; false = use ?
allowPrivateIPs bool // v0.38.5: disable SSRF private IP check
} }
// NewRunner creates a runner with the given sandbox and dependencies. // NewRunner creates a runner with the given sandbox and dependencies.
@@ -97,6 +100,13 @@ func (r *Runner) SetPackagesDir(dir string) {
r.packagesDir = dir r.packagesDir = dir
} }
// SetAllowPrivateIPs disables the SSRF check that blocks connections to
// private/loopback IPs. For self-hosted environments where extensions
// reach internal services. Controlled by EXT_ALLOW_PRIVATE_IPS env var.
func (r *Runner) SetAllowPrivateIPs(allow bool) {
r.allowPrivateIPs = allow
}
// ExecPackage loads a package's script from disk (primary) or manifest // ExecPackage loads a package's script from disk (primary) or manifest
// (legacy) and executes it with modules gated by granted permissions. // (legacy) and executes it with modules gated by granted permissions.
// //
@@ -216,10 +226,13 @@ func (r *Runner) packageLoader(pkgID string, modules map[string]starlark.Value)
} }
// Build predeclared with same modules as entry point // Build predeclared with same modules as entry point
predeclared := make(starlark.StringDict, len(modules)+1) predeclared := make(starlark.StringDict, len(modules)+2)
for k, v := range modules { for k, v := range modules {
predeclared[k] = v predeclared[k] = v
} }
// json is always available (added by ExecWithLoader for entry point;
// sub-modules need it too)
predeclared["json"] = starlarkjson.Module
globals, err := starlark.ExecFile(thread, module, string(data), predeclared) globals, err := starlark.ExecFile(thread, module, string(data), predeclared)
if err != nil { if err != nil {
@@ -299,6 +312,7 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
case models.ExtPermAPIHTTP: case models.ExtPermAPIHTTP:
httpCfg := ParseNetworkAccess(manifest) httpCfg := ParseNetworkAccess(manifest)
httpCfg.AllowPrivateIPs = r.allowPrivateIPs
modules["http"] = BuildHTTPModule(ctx, httpCfg) modules["http"] = BuildHTTPModule(ctx, httpCfg)
case models.ExtPermProviderComplete: case models.ExtPermProviderComplete:

View File

@@ -1392,6 +1392,10 @@ type ConnectionStore interface {
// to the user (personal + team + global). // to the user (personal + team + global).
ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error) ResolveAll(ctx context.Context, userID, connType string) ([]models.ExtConnection, error)
// ListAccessible returns all connections accessible to the user across
// all types: personal + team memberships + global.
ListAccessible(ctx context.Context, userID string) ([]models.ExtConnection, error)
// DeleteByIDAndScope deletes a connection only if it matches the given // DeleteByIDAndScope deletes a connection only if it matches the given
// scope and owner. Returns rows affected (0 or 1). // scope and owner. Returns rows affected (0 or 1).
DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error)

View File

@@ -117,6 +117,26 @@ func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType strin
return scanConnections(rows) return scanConnections(rows)
} }
// ListAccessible returns all connections accessible to the user across all types.
func (s *ConnectionStore) ListAccessible(ctx context.Context, userID string) ([]models.ExtConnection, error) {
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
SELECT %s FROM ext_connections
WHERE is_active = true
AND (
(scope = 'personal' AND owner_id = $1)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
OR (scope = 'global')
)
ORDER BY type ASC, scope ASC, name ASC`, connCols), userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanConnections(rows)
}
// DeleteByIDAndScope deletes a connection only if it matches the scope/owner. // DeleteByIDAndScope deletes a connection only if it matches the scope/owner.
func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) { func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) {
res, err := DB.ExecContext(ctx, res, err := DB.ExecContext(ctx,

View File

@@ -122,6 +122,25 @@ func (s *ConnectionStore) ResolveAll(ctx context.Context, userID, connType strin
return scanConnections(rows) return scanConnections(rows)
} }
func (s *ConnectionStore) ListAccessible(ctx context.Context, userID string) ([]models.ExtConnection, error) {
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
SELECT %s FROM ext_connections
WHERE is_active = 1
AND (
(scope = 'personal' AND owner_id = ?)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = ?
))
OR (scope = 'global')
)
ORDER BY type ASC, scope ASC, name ASC`, connCols), userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanConnections(rows)
}
func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) { func (s *ConnectionStore) DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error) {
res, err := DB.ExecContext(ctx, res, err := DB.ExecContext(ctx,
`DELETE FROM ext_connections WHERE id = ? AND scope = ? AND owner_id = ?`, `DELETE FROM ext_connections WHERE id = ? AND scope = ? AND owner_id = ?`,

View File

@@ -185,25 +185,34 @@ export function ConnectionsSection() {
${connections.length > 0 && html` ${connections.length > 0 && html`
<table class="admin-table"> <table class="admin-table">
<thead><tr><th>Name</th><th>Type</th><th>Status</th><th></th></tr></thead> <thead><tr><th>Name</th><th>Type</th><th>Scope</th><th>Status</th><th></th></tr></thead>
<tbody> <tbody>
${connections.map(c => html` ${connections.map(c => html`
<tr> <tr>
<td style="font-weight:500;font-size:13px;">${esc(c.name)}</td> <td style="font-weight:500;font-size:13px;">${esc(c.name)}</td>
<td style="font-size:12px;color:var(--text-2);">${esc(c.type)}</td> <td style="font-size:12px;color:var(--text-2);">${esc(c.type)}</td>
<td style="font-size:11px;">
<span class="badge" style=${
c.scope === 'global' ? 'background:var(--accent);color:#fff;' :
c.scope === 'team' ? 'background:var(--green);color:#fff;' :
''
}>${esc(c.scope || 'personal')}</span>
</td>
<td style="font-size:12px;"> <td style="font-size:12px;">
${c.is_active ${c.is_active
? html`<span style="color:var(--green);">\u25CF Active</span>` ? html`<span style="color:var(--green);">\u25CF Active</span>`
: html`<span style="color:var(--text-3);">\u25CB Inactive</span>`} : html`<span style="color:var(--text-3);">\u25CB Inactive</span>`}
</td> </td>
<td class="admin-actions-cell" style="white-space:nowrap;"> <td class="admin-actions-cell" style="white-space:nowrap;">
<button class="icon-btn" title="Edit" onClick=${() => openEdit(c)}> ${c.scope === 'personal' ? html`
\u{270F}\u{FE0F} <button class="icon-btn" title="Edit" onClick=${() => openEdit(c)}>
</button> \u{270F}\u{FE0F}
<button class="icon-btn icon-btn-danger" title="Delete" </button>
onClick=${() => del(c)}> <button class="icon-btn icon-btn-danger" title="Delete"
\u{1F5D1} onClick=${() => del(c)}>
</button> \u{1F5D1}
</button>
` : ''}
</td> </td>
</tr> </tr>
`)} `)}