// The GitHub companion app (docs/connected-services.md § companion apps, // docs/integrations.md). Once you've linked GitHub in Integrations (a // fine-grained PAT), this app "installs": it appears in New chat and opens as // its own thread — your repositories, with a drill-in to any repo's open // issues and a form to open one. // // There is NO GitHub sidecar and no new bot: every fetch goes through the host // bridge (ctx.host.connected.requestOp), which seals your token to the // instance's EXISTING bot for one op — read-scoped for lists, write-scoped // only when you submit the new-issue form — and the bot answers with a // non-rendering connect.result. What comes back is bounded by the github // manifest's `pick` projection (integrations/registry/), so only the reviewed // fields ever ride into the thread. The server relays ciphertext throughout. // // It's a TAKEOVER app (no chatMessage): the tables ARE the thread, so repo // and issue data never echoes into chat bubbles, previews, or notifications. import { defineApp } from "../apps.js"; import { LOGO_GITHUB } from "../logos.js"; // Results arrive as pick-projected rows with flattened dot-path keys // ("user.login") — normalise to plain fields once, at fold time, and tolerate // anything malformed by dropping it. const normalizeRepos = (list) => (Array.isArray(list) ? list : []) .filter((r) => r && typeof r.full_name === "string") .map((r) => ({ full_name: r.full_name, description: typeof r.description === "string" ? r.description : "", private: !!r.private, archived: !!r.archived, fork: !!r.fork, html_url: typeof r.html_url === "string" ? r.html_url : "", open_issues: typeof r.open_issues_count === "number" ? r.open_issues_count : null, stars: typeof r.stargazers_count === "number" ? r.stargazers_count : null, pushed_at: typeof r.pushed_at === "string" ? r.pushed_at : "", })); const normalizeIssues = (list) => (Array.isArray(list) ? list : []) .filter((i) => i && typeof i.number === "number" && typeof i.title === "string") .map((i) => ({ number: i.number, title: i.title, html_url: typeof i.html_url === "string" ? i.html_url : "", by: typeof i["user.login"] === "string" ? i["user.login"] : "", comments: typeof i.comments === "number" ? i.comments : 0, updated_at: typeof i.updated_at === "string" ? i.updated_at : "", is_pr: !!i["pull_request.url"], draft: !!i.draft, })); // "3d ago"-style relative stamp for pushed_at/updated_at; "" when unparsable. function ago(iso) { const t = Date.parse(iso || ""); if (!t) return ""; const s = Math.max(0, (Date.now() - t) / 1000); if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`; const d = new Date(t); return d.toISOString().slice(0, 10); } // Bound the folded issues map: keep the most recently fetched handful of // repos so the snapshot never balloons (the repo list itself is one array). const MAX_ISSUE_REPOS = 6; function trimIssues(issues) { const keys = Object.keys(issues); if (keys.length <= MAX_ISSUE_REPOS) return issues; keys.sort((a, b) => (issues[b].fetchedAt || 0) - (issues[a].fetchedAt || 0)); const out = {}; for (const k of keys.slice(0, MAX_ISSUE_REPOS)) out[k] = issues[k]; return out; } // Module-level UI state (transient to the surface, never folded): the open // repo (null = repo list), the live filter, and in-flight flags. let openRepo = null; let filterText = ""; let busy = false; let autoLoading = false; export const app = defineApp({ name: "github", title: "GitHub", glyph: "🐙", mark: "github", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md) logo: LOGO_GITHUB, places: ["you"], // Only listed in New chat once GitHub is linked (shell.js gates on this). requiresService: "github", initialState: () => ({ repos: [], fetchedAt: 0, issues: {} }), // Two events: the fetched repo list (latest refresh replaces), and one // repo's fetched open issues (keyed by full_name, trimmed to a handful). events: { "github.repos": (s, e) => Array.isArray(e.repos) ? { ...s, repos: normalizeRepos(e.repos), fetchedAt: e.ts || s.fetchedAt } : s, "github.issues": (s, e) => typeof e.repo === "string" && Array.isArray(e.issues) ? { ...s, issues: trimIssues({ ...s.issues, [e.repo]: { items: normalizeIssues(e.issues), fetchedAt: e.ts || Date.now() } }) } : s, }, styles: ` .gh-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; } .gh-title { margin: 0; font-size: 18px; overflow-wrap: anywhere; } .gh-sub { margin-left: auto; font-size: 12px; color: var(--muted); white-space: nowrap; } .gh-btn { flex: 0 0 auto; padding: 6px 12px; border-radius: 9px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; cursor: var(--ui-cursor, pointer); } .gh-btn:disabled { opacity: .6; cursor: default; } .gh-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } .gh-filter { width: 100%; box-sizing: border-box; padding: 8px 12px; margin: 0 0 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--panel); color: var(--ink); font: inherit; } .gh-filter:focus { outline: none; border-color: var(--accent); } .gh-scroll { overflow-x: auto; margin: 0 -4px; } .gh-table { width: 100%; border-collapse: collapse; font-size: 14px; } .gh-table th { text-align: left; font-weight: 600; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 6px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; } .gh-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: top; } .gh-table tbody tr:hover { background: var(--bubble-them); } .gh-table tbody tr.gh-active { background: var(--bubble-them); box-shadow: inset 2px 0 0 var(--accent); } .gh-name { font-weight: 500; overflow-wrap: anywhere; } .gh-name a { color: var(--accent); text-decoration: none; } .gh-name a:hover { text-decoration: underline; } .gh-desc { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; } .gh-badge { display: inline-block; font-size: 10px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 1px 7px; margin-left: 6px; vertical-align: 1px; white-space: nowrap; } .gh-c { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } .gh-when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } .gh-empty { color: var(--muted); padding: 8px 0; } .gh-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); } .gh-form { display: flex; flex-direction: column; gap: 8px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--border); border-radius: 12px; } .gh-form input, .gh-form textarea { box-sizing: border-box; width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--panel); color: var(--ink); font: inherit; } .gh-form textarea { min-height: 64px; resize: vertical; } .gh-form-row { display: flex; gap: 8px; justify-content: flex-end; } `, render(state, el, ctx) { const host = ctx.host && ctx.host.connected; // appStateFor returns undefined for a thread with no folded state yet, so // normalise before touching any field. state = state || { repos: [], fetchedAt: 0, issues: {} }; const repos = normalizeRepos(state.repos); const issues = state.issues || {}; const head = document.createElement("div"); head.className = "gh-head"; el.appendChild(head); const note = document.createElement("div"); note.className = "gh-note"; // No privileged bridge (or a stale page that predates requestOp) — the // native app can't seal grants yet; connected services are web-only. if (!host || !host.requestOp) { const title = document.createElement("h2"); title.className = "gh-title"; title.textContent = "GitHub"; head.appendChild(title); const empty = document.createElement("div"); empty.className = "gh-empty"; empty.textContent = "GitHub needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet)."; el.append(empty); return; } // Both fetchers run their WHOLE body under try/finally: a throw anywhere // (even a plain bug) must land in fail() and release `busy` — a wedged // module-level flag silently swallows every later Refresh, which is // exactly the failure that shipped: paint() here hit the temporal dead // zone when the auto-load fired mid-render, threw before the old finally, // and froze the app on "loading…" for good. const fetchRepos = async () => { if (busy) return; busy = true; let res; try { note.textContent = ""; paint(); res = await host.requestOp("github", "repos.list", {}); } catch (e) { res = { ok: false, error: (e && e.message) || String(e) }; } finally { busy = false; autoLoading = false; } if (res && res.ok && typeof res.data === "string") { let rows; try { rows = JSON.parse(res.data); } catch { rows = null; } // Emit as an event: it folds into state (persists via the snapshot and // syncs across devices) and re-renders with the fresh list. if (rows) return ctx.emit({ type: "github.repos", repos: rows }); } fail((res && res.error) || "Couldn't reach GitHub just now.", repos.length > 0); }; const fetchIssues = async (fullName) => { if (busy) return; const [owner, repo] = fullName.split("/"); if (!owner || !repo) return; busy = true; let res; try { note.textContent = ""; paint(); res = await host.requestOp("github", "issues.list", { owner, repo }); } catch (e) { res = { ok: false, error: (e && e.message) || String(e) }; } finally { busy = false; } if (res && res.ok && typeof res.data === "string") { let rows; try { rows = JSON.parse(res.data); } catch { rows = null; } if (rows) return ctx.emit({ type: "github.issues", repo: fullName, issues: rows }); } const have = issues[fullName] && issues[fullName].items.length; fail((res && res.error) || "Couldn't reach GitHub just now.", !!have); }; // Loud failure in the main area when there's nothing to show; a quiet note // when stale data is still on screen — a failed load must never look empty. const fail = (msg, haveData) => { if (haveData) { note.textContent = `Couldn't refresh: ${msg}`; } else { listOrEmpty.className = "gh-empty"; listOrEmpty.textContent = `Couldn't load from GitHub: ${msg}`; } paint(); }; // ---- shared chrome: filter box + list area ----------------------------- const filter = document.createElement("input"); filter.className = "gh-filter"; filter.type = "search"; filter.value = filterText; filter.hidden = true; filter.addEventListener("input", () => { filterText = filter.value; activeIndex = 0; drawList(); }); filter.addEventListener("keydown", (e) => { if (e.key === "ArrowDown") { e.preventDefault(); setActive(activeIndex + 1); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive(activeIndex - 1); } else if (e.key === "Enter") { e.preventDefault(); enterActive(); } else if (e.key === "Escape" && filterText) { e.preventDefault(); filter.value = ""; filterText = ""; activeIndex = 0; drawList(); } }); const listOrEmpty = document.createElement("div"); // Keyboard-nav state shared by both tables. let activeIndex = 0; let currentRows = []; let rowEls = []; const highlight = () => { rowEls.forEach((tr, i) => tr.classList.toggle("gh-active", i === activeIndex)); const active = rowEls[activeIndex]; if (active && active.scrollIntoView) active.scrollIntoView({ block: "nearest" }); }; const setActive = (i) => { if (!currentRows.length) return; activeIndex = Math.max(0, Math.min(i, currentRows.length - 1)); highlight(); }; const enterActive = () => { const row = currentRows[activeIndex]; if (!row) return; if (openRepo) { if (row.html_url) window.open(row.html_url, "_blank", "noopener"); } else { openIssues(row.full_name); } }; const restoreFocus = () => { // Focus the filter so ↑/↓/Enter work immediately — but never steal focus // (e.g. from the new-issue form or the chat composer). if (!document.activeElement || document.activeElement === document.body) filter.focus({ preventScroll: true }); }; // The framework re-renders on fold; a pure view switch (repo list ⇄ one // repo's issues) re-enters render by hand, reading fresh folded state. const redraw = () => { el.textContent = ""; app.render(ctx.getState(), el, ctx); }; const openIssues = (fullName) => { openRepo = fullName; filterText = ""; activeIndex = 0; redraw(); }; // ---- repo list view ---------------------------------------------------- const drawRepos = () => { filter.placeholder = "Filter repositories… (↑ ↓ to move, Enter to open its issues)"; filter.hidden = !repos.length; listOrEmpty.textContent = ""; if (!repos.length) { currentRows = []; rowEls = []; listOrEmpty.className = "gh-empty"; listOrEmpty.textContent = state.fetchedAt ? "No repositories visible to this token." : "No repositories loaded yet — hit Refresh."; return; } const q = filterText.trim().toLowerCase(); const rows = repos.filter((r) => !q || r.full_name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)); currentRows = rows; rowEls = []; if (!rows.length) { listOrEmpty.className = "gh-empty"; listOrEmpty.textContent = `No repositories match “${filterText.trim()}”.`; return; } activeIndex = Math.max(0, Math.min(activeIndex, rows.length - 1)); listOrEmpty.className = ""; const scroll = document.createElement("div"); scroll.className = "gh-scroll"; const table = document.createElement("table"); table.className = "gh-table"; const thead = document.createElement("thead"); const hr = document.createElement("tr"); for (const [label, cls] of [["Repository", ""], ["Issues", "gh-c"], ["Stars", "gh-c"], ["Pushed", "gh-c"]]) { const th = document.createElement("th"); th.textContent = label; if (cls) th.className = cls; hr.appendChild(th); } thead.appendChild(hr); table.appendChild(thead); const tbody = document.createElement("tbody"); rows.forEach((r, i) => { const tr = document.createElement("tr"); tr.onclick = () => setActive(i); tr.ondblclick = () => openIssues(r.full_name); const nameCell = document.createElement("td"); nameCell.className = "gh-name"; const open = document.createElement("a"); open.href = "#"; open.textContent = r.full_name; open.onclick = (e) => { e.preventDefault(); openIssues(r.full_name); }; nameCell.appendChild(open); for (const [flag, label] of [[r.private, "private"], [r.archived, "archived"], [r.fork, "fork"]]) { if (!flag) continue; const b = document.createElement("span"); b.className = "gh-badge"; b.textContent = label; nameCell.appendChild(b); } if (r.description) { const d = document.createElement("div"); d.className = "gh-desc"; d.textContent = r.description; nameCell.appendChild(d); } const issuesCell = document.createElement("td"); issuesCell.className = "gh-c"; issuesCell.textContent = r.open_issues === null ? "—" : String(r.open_issues); const starsCell = document.createElement("td"); starsCell.className = "gh-c"; starsCell.textContent = r.stars === null ? "—" : String(r.stars); const whenCell = document.createElement("td"); whenCell.className = "gh-c gh-when"; whenCell.textContent = ago(r.pushed_at) || "—"; tr.append(nameCell, issuesCell, starsCell, whenCell); rowEls.push(tr); tbody.appendChild(tr); }); table.appendChild(tbody); scroll.appendChild(table); listOrEmpty.appendChild(scroll); highlight(); restoreFocus(); }; // ---- issues view ------------------------------------------------------- const drawIssues = () => { const entry = issues[openRepo]; const items = entry ? entry.items : []; filter.placeholder = "Filter issues… (↑ ↓ to move, Enter to open on github.com)"; filter.hidden = !items.length; listOrEmpty.textContent = ""; if (!items.length) { currentRows = []; rowEls = []; listOrEmpty.className = "gh-empty"; listOrEmpty.textContent = entry ? "No open issues — clear!" : "Loading issues…"; return; } const q = filterText.trim().toLowerCase(); const rows = items.filter((i) => !q || i.title.toLowerCase().includes(q) || String(i.number).includes(q)); currentRows = rows; rowEls = []; if (!rows.length) { listOrEmpty.className = "gh-empty"; listOrEmpty.textContent = `No issues match “${filterText.trim()}”.`; return; } activeIndex = Math.max(0, Math.min(activeIndex, rows.length - 1)); listOrEmpty.className = ""; const scroll = document.createElement("div"); scroll.className = "gh-scroll"; const table = document.createElement("table"); table.className = "gh-table"; const thead = document.createElement("thead"); const hr = document.createElement("tr"); for (const [label, cls] of [["#", ""], ["Title", ""], ["By", ""], ["💬", "gh-c"], ["Updated", "gh-c"]]) { const th = document.createElement("th"); th.textContent = label; if (cls) th.className = cls; hr.appendChild(th); } thead.appendChild(hr); table.appendChild(thead); const tbody = document.createElement("tbody"); rows.forEach((it, i) => { const tr = document.createElement("tr"); tr.onclick = () => setActive(i); const numCell = document.createElement("td"); numCell.className = "gh-when"; numCell.textContent = `#${it.number}`; const titleCell = document.createElement("td"); titleCell.className = "gh-name"; if (it.html_url) { const a = document.createElement("a"); a.href = it.html_url; a.target = "_blank"; a.rel = "noopener noreferrer"; a.textContent = it.title; titleCell.appendChild(a); } else { titleCell.textContent = it.title; } if (it.is_pr) { const b = document.createElement("span"); b.className = "gh-badge"; b.textContent = it.draft ? "draft PR" : "PR"; titleCell.appendChild(b); } const byCell = document.createElement("td"); byCell.className = "gh-when"; byCell.textContent = it.by || "—"; const cCell = document.createElement("td"); cCell.className = "gh-c"; cCell.textContent = it.comments ? String(it.comments) : ""; const whenCell = document.createElement("td"); whenCell.className = "gh-c gh-when"; whenCell.textContent = ago(it.updated_at) || "—"; tr.append(numCell, titleCell, byCell, cCell, whenCell); rowEls.push(tr); tbody.appendChild(tr); }); table.appendChild(tbody); scroll.appendChild(table); listOrEmpty.appendChild(scroll); highlight(); restoreFocus(); }; // The new-issue form: your submission IS the consent for this one write — // the grant minted for it is write-scoped and single-use (docs/integrations.md). const buildIssueForm = () => { const form = document.createElement("div"); form.className = "gh-form"; form.hidden = true; const title = document.createElement("input"); title.placeholder = "Issue title"; title.maxLength = 256; const body = document.createElement("textarea"); body.placeholder = "Describe it (optional)"; const row = document.createElement("div"); row.className = "gh-form-row"; const cancel = document.createElement("button"); cancel.type = "button"; cancel.className = "gh-btn"; cancel.textContent = "Cancel"; cancel.onclick = () => (form.hidden = true); const submit = document.createElement("button"); submit.type = "button"; submit.className = "gh-btn gh-btn-primary"; submit.textContent = "Open issue"; submit.onclick = async () => { const t = title.value.trim(); if (!t) { note.textContent = "Give the issue a title first."; return; } const [owner, repo] = openRepo.split("/"); submit.disabled = true; submit.textContent = "Opening…"; const res = await host.requestOp("github", "issue.create", { owner, repo, title: t, body: body.value.trim() }, "write"); submit.disabled = false; submit.textContent = "Open issue"; if (!res.ok) { note.textContent = `Couldn't open the issue: ${res.error}`; return; } title.value = ""; body.value = ""; form.hidden = true; let opened = ""; try { const d = JSON.parse(res.data); opened = d && d.number ? ` #${d.number}` : ""; } catch {} note.textContent = `Issue${opened} opened ✓`; fetchIssues(openRepo); // pull the fresh list so it shows up }; row.append(cancel, submit); form.append(title, body, row); return form; }; // ---- header + assembly, per view --------------------------------------- const title = document.createElement("h2"); title.className = "gh-title"; const sub = document.createElement("span"); sub.className = "gh-sub"; let drawList; if (openRepo) { const back = document.createElement("button"); back.type = "button"; back.className = "gh-btn"; back.textContent = "‹ Repos"; back.onclick = () => { openRepo = null; filterText = ""; activeIndex = 0; redraw(); }; title.textContent = openRepo; const newIssue = document.createElement("button"); newIssue.type = "button"; newIssue.className = "gh-btn"; newIssue.textContent = "+ New issue"; const refresh = document.createElement("button"); refresh.type = "button"; refresh.className = "gh-btn"; refresh.textContent = "↻"; refresh.onclick = () => fetchIssues(openRepo); head.append(back, title, sub, newIssue, refresh); const form = buildIssueForm(); newIssue.onclick = () => { form.hidden = !form.hidden; if (!form.hidden) form.querySelector("input").focus(); }; el.append(form, filter, listOrEmpty, note); drawList = drawIssues; } else { title.textContent = "Your repositories"; const refresh = document.createElement("button"); refresh.type = "button"; refresh.className = "gh-btn"; refresh.textContent = "↻ Refresh"; refresh.onclick = fetchRepos; head.append(title, sub, refresh); el.append(filter, listOrEmpty, note); drawList = drawRepos; } // Transient bits (busy label, count line) live outside folded state. const paint = () => { sub.textContent = busy ? "loading…" : openRepo ? (issues[openRepo] ? `${issues[openRepo].items.length} open` : "") : repos.length ? `${repos.length} repositor${repos.length === 1 ? "y" : "ies"}` : ""; }; drawList(); paint(); // Auto-loads kick off ONLY after the surface is fully built — the // fetchers call paint(), so firing them mid-render hit paint's temporal // dead zone: a ReferenceError before the busy-reset, and the app wedged // on "loading…" with every later Refresh silently swallowed. if (openRepo) { const entry = issues[openRepo]; // Opened with nothing (or stale > 2 min) → pull once, automatically. if (!busy && (!entry || Date.now() - (entry.fetchedAt || 0) > 120000)) fetchIssues(openRepo); } else if (!state.fetchedAt && !busy && !autoLoading) { autoLoading = true; fetchRepos(); } }, });