Changeset 0.37.16 (#228)
This commit is contained in:
241
packages/git-board/css/main.css
Normal file
241
packages/git-board/css/main.css
Normal file
@@ -0,0 +1,241 @@
|
||||
/* Git Board — Surface Styles */
|
||||
|
||||
.gb-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────── */
|
||||
|
||||
.gb-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gb-header__left,
|
||||
.gb-header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gb-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gb-repo-picker {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
padding: 5px 8px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
/* ── Token Setup ─────────────────────────── */
|
||||
|
||||
.gb-setup {
|
||||
max-width: 480px;
|
||||
margin: 60px auto;
|
||||
text-align: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.gb-setup h2 {
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.gb-setup p {
|
||||
color: var(--text-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
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;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── Kanban Board ────────────────────────── */
|
||||
|
||||
.gb-board {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.gb-column {
|
||||
min-width: 260px;
|
||||
max-width: 320px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gb-column__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.gb-column__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.gb-column__count {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.gb-column__cards {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Cards ───────────────────────────────── */
|
||||
|
||||
.gb-card {
|
||||
display: block;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: border-color var(--transition), background var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gb-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.gb-card--pr {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.gb-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.gb-card__number {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.gb-card__assignee {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gb-card__branch {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin-left: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.gb-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.gb-card__labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.gb-card__labels .badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
.gb-card__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ── Empty state ─────────────────────────── */
|
||||
|
||||
.gb-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: var(--text-3);
|
||||
font-size: 14px;
|
||||
}
|
||||
287
packages/git-board/js/main.js
Normal file
287
packages/git-board/js/main.js
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Git Board — Surface Entry Point
|
||||
*
|
||||
* Kanban board for Gitea/GitHub issues and PRs.
|
||||
* Calls Starlark backend via /s/git-board/api/*.
|
||||
* Per-user API token via extension settings.
|
||||
*/
|
||||
(async function () {
|
||||
'use strict';
|
||||
|
||||
var mount = document.getElementById('extension-mount');
|
||||
if (!mount) return;
|
||||
|
||||
var base = window.__BASE__ || '';
|
||||
var ver = window.__VERSION__ || '0';
|
||||
var slug = 'git-board';
|
||||
|
||||
// ── Boot SDK ───────────────────────────────
|
||||
try {
|
||||
if (!window.preact) {
|
||||
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
|
||||
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
|
||||
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
|
||||
window.preact = { h, render };
|
||||
window.hooks = hooksModule;
|
||||
window.html = htmModule.default.bind(h);
|
||||
}
|
||||
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
|
||||
await sdk.boot();
|
||||
} catch (e) {
|
||||
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var { html } = window;
|
||||
var { useState, useEffect, useCallback, useRef } = hooks;
|
||||
var { render } = preact;
|
||||
|
||||
var API = base + '/s/' + slug + '/api';
|
||||
|
||||
async function api(path) {
|
||||
var token = sw.auth._getToken();
|
||||
var resp = await fetch(API + path, {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
var text = await resp.text();
|
||||
try { return JSON.parse(text); } catch (_) { return { error: text }; }
|
||||
}
|
||||
|
||||
// ── App ────────────────────────────────────
|
||||
|
||||
function App() {
|
||||
var [owner, setOwner] = useState('');
|
||||
var [repo, setRepo] = useState('');
|
||||
var [repos, setRepos] = useState([]);
|
||||
var [board, setBoard] = useState(null);
|
||||
var [loading, setLoading] = useState(false);
|
||||
var [needsToken, setNeedsToken] = useState(false);
|
||||
var menuRef = useRef(null);
|
||||
|
||||
// Mount user menu
|
||||
useEffect(function () {
|
||||
if (menuRef.current && sw.userMenu) {
|
||||
sw.userMenu(menuRef.current, { placement: 'down-right' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load repos on mount
|
||||
useEffect(function () {
|
||||
api('/repos').then(function (d) {
|
||||
if (d.error && d.error.indexOf('failed') !== -1) {
|
||||
setNeedsToken(true);
|
||||
return;
|
||||
}
|
||||
setRepos(d.data || []);
|
||||
if (d.data && d.data.length > 0 && !owner) {
|
||||
setOwner(d.data[0].owner);
|
||||
setRepo(d.data[0].name);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
var loadBoard = useCallback(function () {
|
||||
if (!owner || !repo) return;
|
||||
setLoading(true);
|
||||
api('/board?owner=' + encodeURIComponent(owner) + '&repo=' + encodeURIComponent(repo))
|
||||
.then(function (d) {
|
||||
if (d.error) {
|
||||
sw.toast(d.error, 'error');
|
||||
if (d.error.indexOf('failed') !== -1) setNeedsToken(true);
|
||||
} else {
|
||||
setBoard(d);
|
||||
setNeedsToken(false);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, [owner, repo]);
|
||||
|
||||
useEffect(function () { loadBoard(); }, [loadBoard]);
|
||||
|
||||
if (needsToken) return html`
|
||||
<${TokenSetup} menuRef=${menuRef} />
|
||||
`;
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Repo Picker ────────────────────────────
|
||||
|
||||
function RepoPicker({ repos, owner, repo, onSelect }) {
|
||||
var current = owner + '/' + repo;
|
||||
return html`
|
||||
<select class="gb-repo-picker" value=${current}
|
||||
onChange=${function (e) {
|
||||
var parts = e.target.value.split('/');
|
||||
onSelect(parts[0], parts.slice(1).join('/'));
|
||||
}}>
|
||||
${repos.map(function (r) {
|
||||
return html`<option key=${r.full_name} value=${r.full_name}>${r.full_name}</option>`;
|
||||
})}
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Kanban Board ───────────────────────────
|
||||
|
||||
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 }) {
|
||||
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} title="In Progress" count=${inProgress.length}>
|
||||
${inProgress.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })}
|
||||
<//>
|
||||
<${Column} title="Pull Requests" count=${prs.length}>
|
||||
${prs.map(function (p) { return html`<${PRCard} key=${p.number} pr=${p} />`; })}
|
||||
<//>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function Column({ title, count, children }) {
|
||||
return html`
|
||||
<div class="gb-column">
|
||||
<div class="gb-column__header">
|
||||
<span class="gb-column__title">${title}</span>
|
||||
<span class="gb-column__count">${count || 0}</span>
|
||||
</div>
|
||||
<div class="gb-column__cards">${children}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function IssueCard({ issue }) {
|
||||
return html`
|
||||
<a class="gb-card" href=${issue.html_url} target="_blank" rel="noopener">
|
||||
<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>`}
|
||||
</div>
|
||||
<div class="gb-card__title">${esc(issue.title)}</div>
|
||||
<div class="gb-card__labels">
|
||||
${(issue.labels || []).map(function (l) {
|
||||
return html`<span key=${l} class="badge">${esc(l)}</span>`;
|
||||
})}
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
function PRCard({ pr }) {
|
||||
return html`
|
||||
<a class="gb-card gb-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
|
||||
<div class="gb-card__header">
|
||||
<span class="gb-card__number">#${pr.number}</span>
|
||||
<span class="gb-card__branch">${esc(pr.head)} → ${esc(pr.base)}</span>
|
||||
</div>
|
||||
<div class="gb-card__title">${esc(pr.title)}</div>
|
||||
<div class="gb-card__meta">
|
||||
<span>@${esc(pr.user)}</span>
|
||||
<span>${timeAgo(pr.created_at)}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Utilities ──────────────────────────────
|
||||
|
||||
function esc(s) {
|
||||
var el = document.createElement('span');
|
||||
el.textContent = String(s || '');
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '';
|
||||
var sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (sec < 60) return 'now';
|
||||
var min = Math.floor(sec / 60);
|
||||
if (min < 60) return min + 'm';
|
||||
var hr = Math.floor(min / 60);
|
||||
if (hr < 24) return hr + 'h';
|
||||
return Math.floor(hr / 24) + 'd';
|
||||
}
|
||||
|
||||
// ── Mount ──────────────────────────────────
|
||||
render(html`<${App} />`, mount);
|
||||
console.log('[git-board] Surface mounted');
|
||||
})();
|
||||
125
packages/git-board/manifest.json
Normal file
125
packages/git-board/manifest.json
Normal file
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"id": "git-board",
|
||||
"title": "Git Board",
|
||||
"type": "full",
|
||||
"tier": "starlark",
|
||||
"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.",
|
||||
"author": "switchboard",
|
||||
|
||||
"permissions": ["api.http"],
|
||||
|
||||
"api_routes": [
|
||||
{"method": "GET", "path": "/repos"},
|
||||
{"method": "GET", "path": "/board"},
|
||||
{"method": "GET", "path": "/ci/*"}
|
||||
],
|
||||
|
||||
"tools": [
|
||||
{
|
||||
"name": "list_issues",
|
||||
"description": "List issues for a repository. Returns titles, numbers, labels, assignees, and state.",
|
||||
"parameters": {
|
||||
"owner": {"type": "string", "required": true, "description": "Repository owner or org"},
|
||||
"repo": {"type": "string", "required": true, "description": "Repository name"},
|
||||
"state": {"type": "string", "required": false, "description": "Filter: open (default), closed, all"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_issue",
|
||||
"description": "Get full details for a single issue including body and comments.",
|
||||
"parameters": {
|
||||
"owner": {"type": "string", "required": true},
|
||||
"repo": {"type": "string", "required": true},
|
||||
"number": {"type": "number", "required": true, "description": "Issue number"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a new issue in a repository.",
|
||||
"parameters": {
|
||||
"owner": {"type": "string", "required": true},
|
||||
"repo": {"type": "string", "required": true},
|
||||
"title": {"type": "string", "required": true},
|
||||
"body": {"type": "string", "required": false},
|
||||
"labels": {"type": "string", "required": false, "description": "Comma-separated label names"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "update_issue",
|
||||
"description": "Update an existing issue (title, body, state, labels).",
|
||||
"parameters": {
|
||||
"owner": {"type": "string", "required": true},
|
||||
"repo": {"type": "string", "required": true},
|
||||
"number": {"type": "number", "required": true},
|
||||
"title": {"type": "string", "required": false},
|
||||
"body": {"type": "string", "required": false},
|
||||
"state": {"type": "string", "required": false, "description": "open or closed"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "add_comment",
|
||||
"description": "Add a comment to an issue or pull request.",
|
||||
"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 pull requests for a repository with status, branch, and review state.",
|
||||
"parameters": {
|
||||
"owner": {"type": "string", "required": true},
|
||||
"repo": {"type": "string", "required": true},
|
||||
"state": {"type": "string", "required": false, "description": "open (default), closed, all"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_ci_status",
|
||||
"description": "Get CI/CD job status for a commit or branch reference.",
|
||||
"parameters": {
|
||||
"owner": {"type": "string", "required": true},
|
||||
"repo": {"type": "string", "required": true},
|
||||
"ref": {"type": "string", "required": true, "description": "Branch name, tag, or commit SHA"}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
"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",
|
||||
"description": "Default repository owner for the board view.",
|
||||
"default": ""
|
||||
},
|
||||
"default_repo": {
|
||||
"type": "string",
|
||||
"label": "Default Repository",
|
||||
"description": "Default repository name for the board view.",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
328
packages/git-board/script.star
Normal file
328
packages/git-board/script.star
Normal file
@@ -0,0 +1,328 @@
|
||||
# Git Board — Starlark Backend
|
||||
#
|
||||
# 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)
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Surface API routes (/s/git-board/api/*)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_request(req):
|
||||
path = req["path"]
|
||||
method = req["method"]
|
||||
|
||||
if method == "GET" and path == "/repos":
|
||||
return _handle_repos(req)
|
||||
elif method == "GET" and path == "/board":
|
||||
return _handle_board(req)
|
||||
elif method == "GET" and path.startswith("/ci/"):
|
||||
return _handle_ci(req, 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_board(req):
|
||||
"""Combined issues + PRs for kanban board."""
|
||||
q = req.get("query", {})
|
||||
owner = _str(q.get("owner", "")) or settings.get("default_owner") or ""
|
||||
repo = _str(q.get("repo", "")) or settings.get("default_repo") or ""
|
||||
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", ""),
|
||||
})
|
||||
|
||||
return _resp(200, board)
|
||||
|
||||
|
||||
def _handle_ci(req, 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:
|
||||
return _resp(502, {"error": "CI status request failed"})
|
||||
return _resp(200, data)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# LLM Tools (called by AI during chat)
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def on_tool_call(tool_name, params):
|
||||
platform = _platform()
|
||||
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))
|
||||
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)}
|
||||
|
||||
elif tool_name == "get_issue":
|
||||
num = int(params.get("number", 0))
|
||||
data = _git_get(_issue_path(platform, 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,
|
||||
}
|
||||
|
||||
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)
|
||||
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", "")}
|
||||
|
||||
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)
|
||||
if data == None:
|
||||
return {"error": "failed to update issue"}
|
||||
return {"number": data.get("number", 0), "state": data.get("state", ""), "title": data.get("title", "")}
|
||||
|
||||
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", "")})
|
||||
if data == None:
|
||||
return {"error": "failed to add comment"}
|
||||
return {"id": data.get("id", 0), "body": data.get("body", "")}
|
||||
|
||||
elif tool_name == "list_pull_requests":
|
||||
state = params.get("state", "open")
|
||||
data = _git_get(_prs_path(platform, 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)}
|
||||
|
||||
elif tool_name == "get_ci_status":
|
||||
ref = params.get("ref", "")
|
||||
data = _git_get(_ci_path(platform, 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 {"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
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _resp(status, data):
|
||||
return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
|
||||
|
||||
def _str(v):
|
||||
"""Coerce Starlark query param to string."""
|
||||
if v == None:
|
||||
return ""
|
||||
return str(v)
|
||||
Reference in New Issue
Block a user