From c03ece423032d3726176bbb9a65f5a1116769f6b Mon Sep 17 00:00:00 2001 From: gobha Date: Wed, 25 Mar 2026 22:24:59 +0000 Subject: [PATCH] V0.38.5 git board rewrite (#238) Co-authored-by: gobha Co-committed-by: gobha --- CHANGELOG.md | 43 ++ VERSION | 2 +- docker-compose.yml | 1 + docs/ROADMAP.md | 22 +- packages/git-board/css/main.css | 259 +++++++++- packages/git-board/js/main.js | 388 +++++++++++---- packages/git-board/manifest.json | 30 +- packages/git-board/script.star | 333 ++++--------- packages/gitea-client/manifest.json | 2 +- packages/gitea-client/script.star | 31 +- packages/gitea-client/star/issues.star | 13 +- .../sdk-test-runner/js/domains/git-board.js | 471 ++++++++++++++++++ packages/sdk-test-runner/js/main.js | 1 + server/handlers/connection_resolver.go | 19 +- server/handlers/connections.go | 19 +- server/handlers/ext_api.go | 11 +- server/handlers/packages.go | 5 + server/main.go | 5 + server/sandbox/http_module.go | 12 +- server/sandbox/runner.go | 32 +- server/store/interfaces.go | 4 + server/store/postgres/ext_connection.go | 20 + server/store/sqlite/ext_connection.go | 19 + src/js/sw/surfaces/settings/connections.js | 25 +- 24 files changed, 1354 insertions(+), 413 deletions(-) create mode 100644 packages/sdk-test-runner/js/domains/git-board.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 23466c2..26a9e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # 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 ### Summary diff --git a/VERSION b/VERSION index dcc5e4a..a39873f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.38.2.0 +0.38.5.0 diff --git a/docker-compose.yml b/docker-compose.yml index 3e828a0..3f501fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: STORAGE_BACKEND: pvc STORAGE_PATH: /data/storage CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000} + EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true} LOG_FORMAT: ${LOG_FORMAT:-text} LOG_LEVEL: ${LOG_LEVEL:-info} # Dev seed users — ignored if ENVIRONMENT=production diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6f05fa6..3c95e18 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -54,7 +54,7 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ │ v0.37.17–18 Surfaces ✅ │ │ v0.37.19 Tag ✅ │ │ │ │ - v0.38.0–.5 │ │ + v0.38.0–.5 ✅ │ │ Extension │ │ 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.29–v0.31 ✅) with three new platform primitives: multi-file Starlark packages, extension connections, and library packages. Culminates in a git-board rewrite that validates the 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. @@ -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.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.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.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.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.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. - [ ] UI bug fixes + - [ ] Git Board UserMenu display (text overlap / position on right edge) - [ ] Dead code removal - [ ] Performance audit - [ ] 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 --- @@ -362,7 +370,7 @@ reading Go source code. Team admins build workflows visually. - Full OpenAPI spec v0.36.0 (complete API documentation) ✅ - UI rewrite v0.37.x (Preact+htm migration, all surfaces) ✅ - 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 workflows, visitor portal, analytics) - Polish v0.40.x (bug hunt, dead code, stabilization) diff --git a/packages/git-board/css/main.css b/packages/git-board/css/main.css index 830137e..24fffa7 100644 --- a/packages/git-board/css/main.css +++ b/packages/git-board/css/main.css @@ -43,7 +43,7 @@ max-width: 260px; } -/* ── Token Setup ─────────────────────────── */ +/* ── Connection Setup ─────────────────────── */ .gb-setup { max-width: 480px; @@ -65,27 +65,6 @@ margin: 0 0 20px; } -.gb-setup__form { - display: flex; - gap: 8px; - margin-bottom: 16px; -} - -.gb-setup__input { - flex: 1; - background: var(--bg-raised); - border: 1px solid var(--border); - border-radius: var(--radius); - color: var(--text); - font-family: var(--mono); - font-size: 13px; - padding: 8px 10px; - outline: none; -} - -.gb-setup__input:focus { - border-color: var(--accent); -} .gb-setup__hint { font-size: 12px; @@ -229,6 +208,24 @@ color: var(--text-3); } +/* ── DnD States ─────────────────────────── */ + +.gb-card[draggable="true"] { + cursor: grab; + user-select: none; +} +.gb-card[draggable="true"]:active { + cursor: grabbing; + opacity: 0.6; +} +.gb-column--dragover { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 6%, var(--bg-surface)); +} +.gb-column--dragover .gb-column__header { + border-bottom-color: var(--accent); +} + /* ── Empty state ─────────────────────────── */ .gb-empty { @@ -239,3 +236,221 @@ color: var(--text-3); font-size: 14px; } + +/* ── Issue Detail Modal ─────────────────── */ + +.gb-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 1000; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 40px 16px; + overflow-y: auto; +} + +.gb-modal { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + width: 100%; + max-width: 680px; + max-height: calc(100vh - 80px); + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); +} + +.gb-modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.gb-modal__title-row { + display: flex; + align-items: baseline; + gap: 8px; + flex: 1; + min-width: 0; +} + +.gb-modal__title { + font-size: 18px; + font-weight: 600; + color: var(--text); + margin: 0; + word-break: break-word; +} + +.gb-modal__close { + background: none; + border: none; + color: var(--text-3); + font-size: 18px; + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius); + flex-shrink: 0; +} +.gb-modal__close:hover { + color: var(--text); + background: var(--bg-hover); +} + +.gb-modal__body { + overflow-y: auto; + padding: 16px 20px; + flex: 1; + min-height: 0; +} + +.gb-modal__meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 12px; + font-size: 12px; + color: var(--text-2); +} + +.gb-modal__date { + color: var(--text-3); +} + +.gb-modal__extlink { + margin-left: auto; + color: var(--accent); + text-decoration: none; + font-size: 12px; +} +.gb-modal__extlink:hover { + text-decoration: underline; +} + +.gb-modal__description { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.gb-modal__body-text { + font-family: var(--font); + font-size: 13px; + line-height: 1.6; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + margin: 0; + background: none; + border: none; + padding: 0; +} + +.gb-modal__empty { + color: var(--text-3); + font-size: 13px; + font-style: italic; + margin: 0; +} + +.gb-modal__section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-2); + text-transform: uppercase; + letter-spacing: 0.03em; + margin: 0 0 12px; +} + +/* ── Comments ────────────────────────────── */ + +.gb-comment { + padding: 10px 0; + border-bottom: 1px solid var(--border); +} +.gb-comment:last-child { + border-bottom: none; +} + +.gb-comment__header { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 4px; + font-size: 12px; +} +.gb-comment__header strong { + color: var(--accent); +} + +.gb-comment__date { + color: var(--text-3); + font-size: 11px; +} + +.gb-comment__body { + font-size: 13px; + line-height: 1.5; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; +} + +/* ── Add Comment ─────────────────────────── */ + +.gb-modal__add-comment { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.gb-modal__textarea { + width: 100%; + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: var(--font); + font-size: 13px; + padding: 8px 10px; + resize: vertical; + outline: none; + box-sizing: border-box; +} +.gb-modal__textarea:focus { + border-color: var(--accent); +} + +.gb-modal__actions { + display: flex; + gap: 8px; + margin-top: 8px; + justify-content: flex-end; +} + +/* ── Badge variants ──────────────────────── */ + +.badge--green { + background: var(--green); + color: #fff; +} +.badge--muted { + background: var(--bg-raised); + color: var(--text-3); +} + +.btn-danger { + background: var(--danger, #e53e3e); + color: #fff; + border: none; +} +.btn-danger:hover { + opacity: 0.9; +} diff --git a/packages/git-board/js/main.js b/packages/git-board/js/main.js index 572a8ea..ef4e6fa 100644 --- a/packages/git-board/js/main.js +++ b/packages/git-board/js/main.js @@ -1,9 +1,11 @@ /** * Git Board — Surface Entry Point * - * Kanban board for Gitea/GitHub issues and PRs. + * Kanban board for Gitea issues and PRs. * Calls Starlark backend via /s/git-board/api/*. - * Per-user API token via extension settings. + * Authentication via gitea-client library connections. + * + * Features: DnD between columns, issue detail modal with comments. */ (async function () { 'use strict'; @@ -33,16 +35,20 @@ } var { html } = window; - var { useState, useEffect, useCallback, useRef } = hooks; + var { useState, useEffect, useCallback, useRef, useMemo } = hooks; var { render } = preact; var API = base + '/s/' + slug + '/api'; - async function api(path) { + async function api(path, opts) { var token = sw.auth._getToken(); - var resp = await fetch(API + path, { - headers: { 'Authorization': 'Bearer ' + token } - }); + var fetchOpts = { headers: { 'Authorization': 'Bearer ' + token } }; + if (opts && opts.method) fetchOpts.method = opts.method; + if (opts && opts.body) { + fetchOpts.body = JSON.stringify(opts.body); + fetchOpts.headers['Content-Type'] = 'application/json'; + } + var resp = await fetch(API + path, fetchOpts); var text = await resp.text(); try { return JSON.parse(text); } catch (_) { return { error: text }; } } @@ -50,26 +56,27 @@ // ── App ──────────────────────────────────── function App() { - var [owner, setOwner] = useState(''); - var [repo, setRepo] = useState(''); - var [repos, setRepos] = useState([]); - var [board, setBoard] = useState(null); + var [owner, setOwner] = useState(''); + var [repo, setRepo] = useState(''); + var [repos, setRepos] = useState([]); + var [board, setBoard] = useState(null); var [loading, setLoading] = useState(false); - var [needsToken, setNeedsToken] = useState(false); + var [needsConn, setNeedsConn] = useState(false); + var [modal, setModal] = useState(null); // {owner, repo, number} var menuRef = useRef(null); - // Mount user menu useEffect(function () { if (menuRef.current && sw.userMenu) { - sw.userMenu(menuRef.current, { placement: 'down-right' }); + sw.userMenu(menuRef.current, { placement: 'down-left' }); } }, []); - // Load repos on mount useEffect(function () { api('/repos').then(function (d) { - if (d.error && d.error.indexOf('failed') !== -1) { - setNeedsToken(true); + if (d.error) { + if (d.error.indexOf('no gitea connection configured') !== -1) { + setNeedsConn(true); + } else { sw.toast(d.error, 'error'); } return; } setRepos(d.data || []); @@ -87,10 +94,10 @@ .then(function (d) { if (d.error) { sw.toast(d.error, 'error'); - if (d.error.indexOf('failed') !== -1) setNeedsToken(true); + if (d.error.indexOf('no gitea connection configured') !== -1) setNeedsConn(true); } else { setBoard(d); - setNeedsToken(false); + setNeedsConn(false); } setLoading(false); }); @@ -98,71 +105,102 @@ useEffect(function () { loadBoard(); }, [loadBoard]); - if (needsToken) return html` - <${TokenSetup} menuRef=${menuRef} /> - `; + var onDrop = useCallback(function (issueNumber, targetCol) { + if (!board) return; + var issue = (board.issues || []).find(function (i) { return i.number === issueNumber; }); + if (!issue) return; + + var patch = {}; + if (targetCol === 'open') { + // Move to Open: unassign + reopen + if (issue.state === 'closed') patch.state = 'open'; + // Note: Gitea PATCH /issues doesn't support clearing assignee via empty string, + // but we do our best — the board will re-split on refresh. + } else if (targetCol === 'in_progress') { + // Move to In Progress: assign to current user + ensure open + if (issue.state === 'closed') patch.state = 'open'; + // Gitea needs assignees array — not supported by our simple update_issue. + // For now, just reopen. User assigns via modal. + if (issue.state === 'closed') patch.state = 'open'; + } else if (targetCol === 'done') { + patch.state = 'closed'; + } + + if (!patch.state) return; // no meaningful change + + // Optimistic update + var newIssues = (board.issues || []).map(function (i) { + if (i.number !== issueNumber) return i; + var copy = {}; + for (var k in i) copy[k] = i[k]; + if (patch.state) copy.state = patch.state; + return copy; + }); + setBoard({ issues: newIssues, pull_requests: board.pull_requests || [] }); + + api('/issue/' + owner + '/' + repo + '/' + issueNumber, { + method: 'POST', body: patch + }).then(function (d) { + if (d.error) { + sw.toast('Update failed: ' + d.error, 'error'); + loadBoard(); // revert + } + }); + }, [board, owner, repo, loadBoard]); + + var openModal = useCallback(function (number) { + setModal({ owner: owner, repo: repo, number: number }); + }, [owner, repo]); + + var closeModal = useCallback(function (changed) { + setModal(null); + if (changed) loadBoard(); + }, [loadBoard]); return html` -
-
-
-

Git Board

- <${RepoPicker} repos=${repos} owner=${owner} repo=${repo} - onSelect=${function (o, r) { setOwner(o); setRepo(r); }} /> -
-
- -
-
-
- - ${board ? html`<${Board} data=${board} />` : html` -
${loading ? 'Loading…' : 'Select a repository'}
- `} -
+
+ ${needsConn ? html`<${ConnectionSetup} />` : html` +
+
+
+

Git Board

+ <${RepoPicker} repos=${repos} owner=${owner} repo=${repo} + onSelect=${function (o, r) { setOwner(o); setRepo(r); }} /> +
+
+ +
+
+ ${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html` +
${loading ? 'Loading…' : 'Select a repository'}
+ `} +
+ `} + ${modal && html`<${IssueModal} ...${modal} onClose=${closeModal} />`} `; } - // ── Token Setup ──────────────────────────── - - function TokenSetup({ menuRef }) { - var [token, setToken] = useState(''); - var [saving, setSaving] = useState(false); - - async function save() { - if (!token.trim()) return; - setSaving(true); - try { - await sw.api.extensions.updateUserSettings('git-board', { api_token: token.trim() }); - sw.toast('Token saved — reloading', 'success'); - setTimeout(function () { location.reload(); }, 500); - } catch (e) { - sw.toast(e.message || 'Failed to save', 'error'); - setSaving(false); - } - } + // ── Connection Setup ───────────────────────── + function ConnectionSetup() { return html`

Git Board

-
-

Connect Your Account

-

Enter your personal access token for Gitea or GitHub.
- The admin configures the platform URL. You provide your own token.

-
- - -
-

Token is stored in your personal extension settings — not shared with other users.

+

Connect to Gitea

+

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

+

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

+ +

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

`; @@ -185,41 +223,52 @@ `; } - // ── Kanban Board ─────────────────────────── + // ── Kanban Board with DnD ───────────────── - var COLUMNS = [ - { key: 'open', title: 'Open', filter: function (i) { return i.state === 'open'; } }, - { key: 'in_progress', title: 'In Progress', filter: function (i) { return i.assignee && i.state === 'open'; } }, - { key: 'prs', title: 'Pull Requests', isPR: true }, - { key: 'closed', title: 'Done', filter: function (i) { return i.state === 'closed'; } }, - ]; - - function Board({ data }) { + function Board({ data, onDrop, onCardClick }) { var issues = data.issues || []; var prs = data.pull_requests || []; - // Split issues: unassigned open vs assigned open var openUnassigned = issues.filter(function (i) { return i.state === 'open' && !i.assignee; }); var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; }); return html`
- <${Column} title="Open" count=${openUnassigned.length}> - ${openUnassigned.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })} + <${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}> + ${openUnassigned.map(function (i) { + return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`; + })} - <${Column} title="In Progress" count=${inProgress.length}> - ${inProgress.map(function (i) { return html`<${IssueCard} key=${i.number} issue=${i} />`; })} + <${Column} id="in_progress" title="In Progress" count=${inProgress.length} onDrop=${onDrop}> + ${inProgress.map(function (i) { + return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`; + })} - <${Column} title="Pull Requests" count=${prs.length}> + <${Column} id="done" title="Done" count=${0} onDrop=${onDrop}> + + <${Column} id="prs" title="Pull Requests" count=${prs.length}> ${prs.map(function (p) { return html`<${PRCard} key=${p.number} pr=${p} />`; })}
`; } - 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` -
+
${title} ${count || 0} @@ -229,9 +278,17 @@ `; } - function IssueCard({ issue }) { + function IssueCard({ issue, onClick }) { return html` - + `; } @@ -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` +
+
+
+
+ ${loading ? 'Loading…' : html` + #${number} +

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

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

No description.

`} +
+ +
+

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

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

No comments yet.

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