| 1 | // The GitHub companion app (docs/connected-services.md § companion apps, |
| 2 | // docs/integrations.md). Once you've linked GitHub in Integrations (a |
| 3 | // fine-grained PAT), this app "installs": it appears in New chat and opens as |
| 4 | // its own thread — your repositories, with a drill-in to any repo's open |
| 5 | // issues and a form to open one. |
| 6 | // |
| 7 | // There is NO GitHub sidecar and no new bot: every fetch goes through the host |
| 8 | // bridge (ctx.host.connected.requestOp), which seals your token to the |
| 9 | // instance's EXISTING bot for one op — read-scoped for lists, write-scoped |
| 10 | // only when you submit the new-issue form — and the bot answers with a |
| 11 | // non-rendering connect.result. What comes back is bounded by the github |
| 12 | // manifest's `pick` projection (integrations/registry/), so only the reviewed |
| 13 | // fields ever ride into the thread. The server relays ciphertext throughout. |
| 14 | // |
| 15 | // It's a TAKEOVER app (no chatMessage): the tables ARE the thread, so repo |
| 16 | // and issue data never echoes into chat bubbles, previews, or notifications. |
| 17 | import { defineApp } from "../apps.js"; |
| 18 | import { LOGO_GITHUB } from "../logos.js"; |
| 19 | |
| 20 | // Results arrive as pick-projected rows with flattened dot-path keys |
| 21 | // ("user.login") — normalise to plain fields once, at fold time, and tolerate |
| 22 | // anything malformed by dropping it. |
| 23 | const normalizeRepos = (list) => |
| 24 | (Array.isArray(list) ? list : []) |
| 25 | .filter((r) => r && typeof r.full_name === "string") |
| 26 | .map((r) => ({ |
| 27 | full_name: r.full_name, |
| 28 | description: typeof r.description === "string" ? r.description : "", |
| 29 | private: !!r.private, |
| 30 | archived: !!r.archived, |
| 31 | fork: !!r.fork, |
| 32 | html_url: typeof r.html_url === "string" ? r.html_url : "", |
| 33 | open_issues: typeof r.open_issues_count === "number" ? r.open_issues_count : null, |
| 34 | stars: typeof r.stargazers_count === "number" ? r.stargazers_count : null, |
| 35 | pushed_at: typeof r.pushed_at === "string" ? r.pushed_at : "", |
| 36 | })); |
| 37 | |
| 38 | const normalizeIssues = (list) => |
| 39 | (Array.isArray(list) ? list : []) |
| 40 | .filter((i) => i && typeof i.number === "number" && typeof i.title === "string") |
| 41 | .map((i) => ({ |
| 42 | number: i.number, |
| 43 | title: i.title, |
| 44 | html_url: typeof i.html_url === "string" ? i.html_url : "", |
| 45 | by: typeof i["user.login"] === "string" ? i["user.login"] : "", |
| 46 | comments: typeof i.comments === "number" ? i.comments : 0, |
| 47 | updated_at: typeof i.updated_at === "string" ? i.updated_at : "", |
| 48 | is_pr: !!i["pull_request.url"], |
| 49 | draft: !!i.draft, |
| 50 | })); |
| 51 | |
| 52 | // "3d ago"-style relative stamp for pushed_at/updated_at; "" when unparsable. |
| 53 | function ago(iso) { |
| 54 | const t = Date.parse(iso || ""); |
| 55 | if (!t) return ""; |
| 56 | const s = Math.max(0, (Date.now() - t) / 1000); |
| 57 | if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m ago`; |
| 58 | if (s < 86400) return `${Math.floor(s / 3600)}h ago`; |
| 59 | if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`; |
| 60 | const d = new Date(t); |
| 61 | return d.toISOString().slice(0, 10); |
| 62 | } |
| 63 | |
| 64 | // Bound the folded issues map: keep the most recently fetched handful of |
| 65 | // repos so the snapshot never balloons (the repo list itself is one array). |
| 66 | const MAX_ISSUE_REPOS = 6; |
| 67 | function trimIssues(issues) { |
| 68 | const keys = Object.keys(issues); |
| 69 | if (keys.length <= MAX_ISSUE_REPOS) return issues; |
| 70 | keys.sort((a, b) => (issues[b].fetchedAt || 0) - (issues[a].fetchedAt || 0)); |
| 71 | const out = {}; |
| 72 | for (const k of keys.slice(0, MAX_ISSUE_REPOS)) out[k] = issues[k]; |
| 73 | return out; |
| 74 | } |
| 75 | |
| 76 | // Module-level UI state (transient to the surface, never folded): the open |
| 77 | // repo (null = repo list), the live filter, and in-flight flags. |
| 78 | let openRepo = null; |
| 79 | let filterText = ""; |
| 80 | let busy = false; |
| 81 | let autoLoading = false; |
| 82 | |
| 83 | export const app = defineApp({ |
| 84 | name: "github", |
| 85 | title: "GitHub", |
| 86 | glyph: "🐙", |
| 87 | mark: "github", // native chrome: the vendored template asset iOS/Mac render (docs/icons.md) |
| 88 | logo: LOGO_GITHUB, |
| 89 | places: ["you"], |
| 90 | // Only listed in New chat once GitHub is linked (shell.js gates on this). |
| 91 | requiresService: "github", |
| 92 | initialState: () => ({ repos: [], fetchedAt: 0, issues: {} }), |
| 93 | |
| 94 | // Two events: the fetched repo list (latest refresh replaces), and one |
| 95 | // repo's fetched open issues (keyed by full_name, trimmed to a handful). |
| 96 | events: { |
| 97 | "github.repos": (s, e) => |
| 98 | Array.isArray(e.repos) ? { ...s, repos: normalizeRepos(e.repos), fetchedAt: e.ts || s.fetchedAt } : s, |
| 99 | "github.issues": (s, e) => |
| 100 | typeof e.repo === "string" && Array.isArray(e.issues) |
| 101 | ? { ...s, issues: trimIssues({ ...s.issues, [e.repo]: { items: normalizeIssues(e.issues), fetchedAt: e.ts || Date.now() } }) } |
| 102 | : s, |
| 103 | }, |
| 104 | |
| 105 | styles: ` |
| 106 | .gh-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; } |
| 107 | .gh-title { margin: 0; font-size: 18px; overflow-wrap: anywhere; } |
| 108 | .gh-sub { margin-left: auto; font-size: 12px; color: var(--muted); white-space: nowrap; } |
| 109 | .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); } |
| 110 | .gh-btn:disabled { opacity: .6; cursor: default; } |
| 111 | .gh-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } |
| 112 | .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; } |
| 113 | .gh-filter:focus { outline: none; border-color: var(--accent); } |
| 114 | .gh-scroll { overflow-x: auto; margin: 0 -4px; } |
| 115 | .gh-table { width: 100%; border-collapse: collapse; font-size: 14px; } |
| 116 | .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; } |
| 117 | .gh-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: top; } |
| 118 | .gh-table tbody tr:hover { background: var(--bubble-them); } |
| 119 | .gh-table tbody tr.gh-active { background: var(--bubble-them); box-shadow: inset 2px 0 0 var(--accent); } |
| 120 | .gh-name { font-weight: 500; overflow-wrap: anywhere; } |
| 121 | .gh-name a { color: var(--accent); text-decoration: none; } |
| 122 | .gh-name a:hover { text-decoration: underline; } |
| 123 | .gh-desc { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; } |
| 124 | .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; } |
| 125 | .gh-c { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } |
| 126 | .gh-when { color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } |
| 127 | .gh-empty { color: var(--muted); padding: 8px 0; } |
| 128 | .gh-note { margin-top: 12px; min-height: 18px; font-size: 13px; color: var(--muted); } |
| 129 | .gh-form { display: flex; flex-direction: column; gap: 8px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--border); border-radius: 12px; } |
| 130 | .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; } |
| 131 | .gh-form textarea { min-height: 64px; resize: vertical; } |
| 132 | .gh-form-row { display: flex; gap: 8px; justify-content: flex-end; } |
| 133 | `, |
| 134 | |
| 135 | render(state, el, ctx) { |
| 136 | const host = ctx.host && ctx.host.connected; |
| 137 | // appStateFor returns undefined for a thread with no folded state yet, so |
| 138 | // normalise before touching any field. |
| 139 | state = state || { repos: [], fetchedAt: 0, issues: {} }; |
| 140 | const repos = normalizeRepos(state.repos); |
| 141 | const issues = state.issues || {}; |
| 142 | |
| 143 | const head = document.createElement("div"); |
| 144 | head.className = "gh-head"; |
| 145 | el.appendChild(head); |
| 146 | |
| 147 | const note = document.createElement("div"); |
| 148 | note.className = "gh-note"; |
| 149 | |
| 150 | // No privileged bridge (or a stale page that predates requestOp) — the |
| 151 | // native app can't seal grants yet; connected services are web-only. |
| 152 | if (!host || !host.requestOp) { |
| 153 | const title = document.createElement("h2"); |
| 154 | title.className = "gh-title"; |
| 155 | title.textContent = "GitHub"; |
| 156 | head.appendChild(title); |
| 157 | const empty = document.createElement("div"); |
| 158 | empty.className = "gh-empty"; |
| 159 | empty.textContent = "GitHub needs the web app for now — open this chat in a web browser (connecting isn't supported in the app yet)."; |
| 160 | el.append(empty); |
| 161 | return; |
| 162 | } |
| 163 | |
| 164 | // Both fetchers run their WHOLE body under try/finally: a throw anywhere |
| 165 | // (even a plain bug) must land in fail() and release `busy` — a wedged |
| 166 | // module-level flag silently swallows every later Refresh, which is |
| 167 | // exactly the failure that shipped: paint() here hit the temporal dead |
| 168 | // zone when the auto-load fired mid-render, threw before the old finally, |
| 169 | // and froze the app on "loading…" for good. |
| 170 | const fetchRepos = async () => { |
| 171 | if (busy) return; |
| 172 | busy = true; |
| 173 | let res; |
| 174 | try { |
| 175 | note.textContent = ""; |
| 176 | paint(); |
| 177 | res = await host.requestOp("github", "repos.list", {}); |
| 178 | } catch (e) { |
| 179 | res = { ok: false, error: (e && e.message) || String(e) }; |
| 180 | } finally { |
| 181 | busy = false; |
| 182 | autoLoading = false; |
| 183 | } |
| 184 | if (res && res.ok && typeof res.data === "string") { |
| 185 | let rows; |
| 186 | try { |
| 187 | rows = JSON.parse(res.data); |
| 188 | } catch { |
| 189 | rows = null; |
| 190 | } |
| 191 | // Emit as an event: it folds into state (persists via the snapshot and |
| 192 | // syncs across devices) and re-renders with the fresh list. |
| 193 | if (rows) return ctx.emit({ type: "github.repos", repos: rows }); |
| 194 | } |
| 195 | fail((res && res.error) || "Couldn't reach GitHub just now.", repos.length > 0); |
| 196 | }; |
| 197 | |
| 198 | const fetchIssues = async (fullName) => { |
| 199 | if (busy) return; |
| 200 | const [owner, repo] = fullName.split("/"); |
| 201 | if (!owner || !repo) return; |
| 202 | busy = true; |
| 203 | let res; |
| 204 | try { |
| 205 | note.textContent = ""; |
| 206 | paint(); |
| 207 | res = await host.requestOp("github", "issues.list", { owner, repo }); |
| 208 | } catch (e) { |
| 209 | res = { ok: false, error: (e && e.message) || String(e) }; |
| 210 | } finally { |
| 211 | busy = false; |
| 212 | } |
| 213 | if (res && res.ok && typeof res.data === "string") { |
| 214 | let rows; |
| 215 | try { |
| 216 | rows = JSON.parse(res.data); |
| 217 | } catch { |
| 218 | rows = null; |
| 219 | } |
| 220 | if (rows) return ctx.emit({ type: "github.issues", repo: fullName, issues: rows }); |
| 221 | } |
| 222 | const have = issues[fullName] && issues[fullName].items.length; |
| 223 | fail((res && res.error) || "Couldn't reach GitHub just now.", !!have); |
| 224 | }; |
| 225 | |
| 226 | // Loud failure in the main area when there's nothing to show; a quiet note |
| 227 | // when stale data is still on screen — a failed load must never look empty. |
| 228 | const fail = (msg, haveData) => { |
| 229 | if (haveData) { |
| 230 | note.textContent = `Couldn't refresh: ${msg}`; |
| 231 | } else { |
| 232 | listOrEmpty.className = "gh-empty"; |
| 233 | listOrEmpty.textContent = `Couldn't load from GitHub: ${msg}`; |
| 234 | } |
| 235 | paint(); |
| 236 | }; |
| 237 | |
| 238 | // ---- shared chrome: filter box + list area ----------------------------- |
| 239 | const filter = document.createElement("input"); |
| 240 | filter.className = "gh-filter"; |
| 241 | filter.type = "search"; |
| 242 | filter.value = filterText; |
| 243 | filter.hidden = true; |
| 244 | filter.addEventListener("input", () => { |
| 245 | filterText = filter.value; |
| 246 | activeIndex = 0; |
| 247 | drawList(); |
| 248 | }); |
| 249 | filter.addEventListener("keydown", (e) => { |
| 250 | if (e.key === "ArrowDown") { |
| 251 | e.preventDefault(); |
| 252 | setActive(activeIndex + 1); |
| 253 | } else if (e.key === "ArrowUp") { |
| 254 | e.preventDefault(); |
| 255 | setActive(activeIndex - 1); |
| 256 | } else if (e.key === "Enter") { |
| 257 | e.preventDefault(); |
| 258 | enterActive(); |
| 259 | } else if (e.key === "Escape" && filterText) { |
| 260 | e.preventDefault(); |
| 261 | filter.value = ""; |
| 262 | filterText = ""; |
| 263 | activeIndex = 0; |
| 264 | drawList(); |
| 265 | } |
| 266 | }); |
| 267 | |
| 268 | const listOrEmpty = document.createElement("div"); |
| 269 | |
| 270 | // Keyboard-nav state shared by both tables. |
| 271 | let activeIndex = 0; |
| 272 | let currentRows = []; |
| 273 | let rowEls = []; |
| 274 | const highlight = () => { |
| 275 | rowEls.forEach((tr, i) => tr.classList.toggle("gh-active", i === activeIndex)); |
| 276 | const active = rowEls[activeIndex]; |
| 277 | if (active && active.scrollIntoView) active.scrollIntoView({ block: "nearest" }); |
| 278 | }; |
| 279 | const setActive = (i) => { |
| 280 | if (!currentRows.length) return; |
| 281 | activeIndex = Math.max(0, Math.min(i, currentRows.length - 1)); |
| 282 | highlight(); |
| 283 | }; |
| 284 | const enterActive = () => { |
| 285 | const row = currentRows[activeIndex]; |
| 286 | if (!row) return; |
| 287 | if (openRepo) { |
| 288 | if (row.html_url) window.open(row.html_url, "_blank", "noopener"); |
| 289 | } else { |
| 290 | openIssues(row.full_name); |
| 291 | } |
| 292 | }; |
| 293 | const restoreFocus = () => { |
| 294 | // Focus the filter so ↑/↓/Enter work immediately — but never steal focus |
| 295 | // (e.g. from the new-issue form or the chat composer). |
| 296 | if (!document.activeElement || document.activeElement === document.body) filter.focus({ preventScroll: true }); |
| 297 | }; |
| 298 | |
| 299 | // The framework re-renders on fold; a pure view switch (repo list ⇄ one |
| 300 | // repo's issues) re-enters render by hand, reading fresh folded state. |
| 301 | const redraw = () => { |
| 302 | el.textContent = ""; |
| 303 | app.render(ctx.getState(), el, ctx); |
| 304 | }; |
| 305 | const openIssues = (fullName) => { |
| 306 | openRepo = fullName; |
| 307 | filterText = ""; |
| 308 | activeIndex = 0; |
| 309 | redraw(); |
| 310 | }; |
| 311 | |
| 312 | // ---- repo list view ---------------------------------------------------- |
| 313 | const drawRepos = () => { |
| 314 | filter.placeholder = "Filter repositories… (↑ ↓ to move, Enter to open its issues)"; |
| 315 | filter.hidden = !repos.length; |
| 316 | listOrEmpty.textContent = ""; |
| 317 | if (!repos.length) { |
| 318 | currentRows = []; |
| 319 | rowEls = []; |
| 320 | listOrEmpty.className = "gh-empty"; |
| 321 | listOrEmpty.textContent = state.fetchedAt ? "No repositories visible to this token." : "No repositories loaded yet — hit Refresh."; |
| 322 | return; |
| 323 | } |
| 324 | const q = filterText.trim().toLowerCase(); |
| 325 | const rows = repos.filter((r) => !q || r.full_name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)); |
| 326 | currentRows = rows; |
| 327 | rowEls = []; |
| 328 | if (!rows.length) { |
| 329 | listOrEmpty.className = "gh-empty"; |
| 330 | listOrEmpty.textContent = `No repositories match “${filterText.trim()}”.`; |
| 331 | return; |
| 332 | } |
| 333 | activeIndex = Math.max(0, Math.min(activeIndex, rows.length - 1)); |
| 334 | listOrEmpty.className = ""; |
| 335 | const scroll = document.createElement("div"); |
| 336 | scroll.className = "gh-scroll"; |
| 337 | const table = document.createElement("table"); |
| 338 | table.className = "gh-table"; |
| 339 | const thead = document.createElement("thead"); |
| 340 | const hr = document.createElement("tr"); |
| 341 | for (const [label, cls] of [["Repository", ""], ["Issues", "gh-c"], ["Stars", "gh-c"], ["Pushed", "gh-c"]]) { |
| 342 | const th = document.createElement("th"); |
| 343 | th.textContent = label; |
| 344 | if (cls) th.className = cls; |
| 345 | hr.appendChild(th); |
| 346 | } |
| 347 | thead.appendChild(hr); |
| 348 | table.appendChild(thead); |
| 349 | const tbody = document.createElement("tbody"); |
| 350 | rows.forEach((r, i) => { |
| 351 | const tr = document.createElement("tr"); |
| 352 | tr.onclick = () => setActive(i); |
| 353 | tr.ondblclick = () => openIssues(r.full_name); |
| 354 | |
| 355 | const nameCell = document.createElement("td"); |
| 356 | nameCell.className = "gh-name"; |
| 357 | const open = document.createElement("a"); |
| 358 | open.href = "#"; |
| 359 | open.textContent = r.full_name; |
| 360 | open.onclick = (e) => { |
| 361 | e.preventDefault(); |
| 362 | openIssues(r.full_name); |
| 363 | }; |
| 364 | nameCell.appendChild(open); |
| 365 | for (const [flag, label] of [[r.private, "private"], [r.archived, "archived"], [r.fork, "fork"]]) { |
| 366 | if (!flag) continue; |
| 367 | const b = document.createElement("span"); |
| 368 | b.className = "gh-badge"; |
| 369 | b.textContent = label; |
| 370 | nameCell.appendChild(b); |
| 371 | } |
| 372 | if (r.description) { |
| 373 | const d = document.createElement("div"); |
| 374 | d.className = "gh-desc"; |
| 375 | d.textContent = r.description; |
| 376 | nameCell.appendChild(d); |
| 377 | } |
| 378 | |
| 379 | const issuesCell = document.createElement("td"); |
| 380 | issuesCell.className = "gh-c"; |
| 381 | issuesCell.textContent = r.open_issues === null ? "—" : String(r.open_issues); |
| 382 | const starsCell = document.createElement("td"); |
| 383 | starsCell.className = "gh-c"; |
| 384 | starsCell.textContent = r.stars === null ? "—" : String(r.stars); |
| 385 | const whenCell = document.createElement("td"); |
| 386 | whenCell.className = "gh-c gh-when"; |
| 387 | whenCell.textContent = ago(r.pushed_at) || "—"; |
| 388 | |
| 389 | tr.append(nameCell, issuesCell, starsCell, whenCell); |
| 390 | rowEls.push(tr); |
| 391 | tbody.appendChild(tr); |
| 392 | }); |
| 393 | table.appendChild(tbody); |
| 394 | scroll.appendChild(table); |
| 395 | listOrEmpty.appendChild(scroll); |
| 396 | highlight(); |
| 397 | restoreFocus(); |
| 398 | }; |
| 399 | |
| 400 | // ---- issues view ------------------------------------------------------- |
| 401 | const drawIssues = () => { |
| 402 | const entry = issues[openRepo]; |
| 403 | const items = entry ? entry.items : []; |
| 404 | filter.placeholder = "Filter issues… (↑ ↓ to move, Enter to open on github.com)"; |
| 405 | filter.hidden = !items.length; |
| 406 | listOrEmpty.textContent = ""; |
| 407 | if (!items.length) { |
| 408 | currentRows = []; |
| 409 | rowEls = []; |
| 410 | listOrEmpty.className = "gh-empty"; |
| 411 | listOrEmpty.textContent = entry ? "No open issues — clear!" : "Loading issues…"; |
| 412 | return; |
| 413 | } |
| 414 | const q = filterText.trim().toLowerCase(); |
| 415 | const rows = items.filter((i) => !q || i.title.toLowerCase().includes(q) || String(i.number).includes(q)); |
| 416 | currentRows = rows; |
| 417 | rowEls = []; |
| 418 | if (!rows.length) { |
| 419 | listOrEmpty.className = "gh-empty"; |
| 420 | listOrEmpty.textContent = `No issues match “${filterText.trim()}”.`; |
| 421 | return; |
| 422 | } |
| 423 | activeIndex = Math.max(0, Math.min(activeIndex, rows.length - 1)); |
| 424 | listOrEmpty.className = ""; |
| 425 | const scroll = document.createElement("div"); |
| 426 | scroll.className = "gh-scroll"; |
| 427 | const table = document.createElement("table"); |
| 428 | table.className = "gh-table"; |
| 429 | const thead = document.createElement("thead"); |
| 430 | const hr = document.createElement("tr"); |
| 431 | for (const [label, cls] of [["#", ""], ["Title", ""], ["By", ""], ["💬", "gh-c"], ["Updated", "gh-c"]]) { |
| 432 | const th = document.createElement("th"); |
| 433 | th.textContent = label; |
| 434 | if (cls) th.className = cls; |
| 435 | hr.appendChild(th); |
| 436 | } |
| 437 | thead.appendChild(hr); |
| 438 | table.appendChild(thead); |
| 439 | const tbody = document.createElement("tbody"); |
| 440 | rows.forEach((it, i) => { |
| 441 | const tr = document.createElement("tr"); |
| 442 | tr.onclick = () => setActive(i); |
| 443 | |
| 444 | const numCell = document.createElement("td"); |
| 445 | numCell.className = "gh-when"; |
| 446 | numCell.textContent = `#${it.number}`; |
| 447 | const titleCell = document.createElement("td"); |
| 448 | titleCell.className = "gh-name"; |
| 449 | if (it.html_url) { |
| 450 | const a = document.createElement("a"); |
| 451 | a.href = it.html_url; |
| 452 | a.target = "_blank"; |
| 453 | a.rel = "noopener noreferrer"; |
| 454 | a.textContent = it.title; |
| 455 | titleCell.appendChild(a); |
| 456 | } else { |
| 457 | titleCell.textContent = it.title; |
| 458 | } |
| 459 | if (it.is_pr) { |
| 460 | const b = document.createElement("span"); |
| 461 | b.className = "gh-badge"; |
| 462 | b.textContent = it.draft ? "draft PR" : "PR"; |
| 463 | titleCell.appendChild(b); |
| 464 | } |
| 465 | const byCell = document.createElement("td"); |
| 466 | byCell.className = "gh-when"; |
| 467 | byCell.textContent = it.by || "—"; |
| 468 | const cCell = document.createElement("td"); |
| 469 | cCell.className = "gh-c"; |
| 470 | cCell.textContent = it.comments ? String(it.comments) : ""; |
| 471 | const whenCell = document.createElement("td"); |
| 472 | whenCell.className = "gh-c gh-when"; |
| 473 | whenCell.textContent = ago(it.updated_at) || "—"; |
| 474 | |
| 475 | tr.append(numCell, titleCell, byCell, cCell, whenCell); |
| 476 | rowEls.push(tr); |
| 477 | tbody.appendChild(tr); |
| 478 | }); |
| 479 | table.appendChild(tbody); |
| 480 | scroll.appendChild(table); |
| 481 | listOrEmpty.appendChild(scroll); |
| 482 | highlight(); |
| 483 | restoreFocus(); |
| 484 | }; |
| 485 | |
| 486 | // The new-issue form: your submission IS the consent for this one write — |
| 487 | // the grant minted for it is write-scoped and single-use (docs/integrations.md). |
| 488 | const buildIssueForm = () => { |
| 489 | const form = document.createElement("div"); |
| 490 | form.className = "gh-form"; |
| 491 | form.hidden = true; |
| 492 | const title = document.createElement("input"); |
| 493 | title.placeholder = "Issue title"; |
| 494 | title.maxLength = 256; |
| 495 | const body = document.createElement("textarea"); |
| 496 | body.placeholder = "Describe it (optional)"; |
| 497 | const row = document.createElement("div"); |
| 498 | row.className = "gh-form-row"; |
| 499 | const cancel = document.createElement("button"); |
| 500 | cancel.type = "button"; |
| 501 | cancel.className = "gh-btn"; |
| 502 | cancel.textContent = "Cancel"; |
| 503 | cancel.onclick = () => (form.hidden = true); |
| 504 | const submit = document.createElement("button"); |
| 505 | submit.type = "button"; |
| 506 | submit.className = "gh-btn gh-btn-primary"; |
| 507 | submit.textContent = "Open issue"; |
| 508 | submit.onclick = async () => { |
| 509 | const t = title.value.trim(); |
| 510 | if (!t) { |
| 511 | note.textContent = "Give the issue a title first."; |
| 512 | return; |
| 513 | } |
| 514 | const [owner, repo] = openRepo.split("/"); |
| 515 | submit.disabled = true; |
| 516 | submit.textContent = "Opening…"; |
| 517 | const res = await host.requestOp("github", "issue.create", { owner, repo, title: t, body: body.value.trim() }, "write"); |
| 518 | submit.disabled = false; |
| 519 | submit.textContent = "Open issue"; |
| 520 | if (!res.ok) { |
| 521 | note.textContent = `Couldn't open the issue: ${res.error}`; |
| 522 | return; |
| 523 | } |
| 524 | title.value = ""; |
| 525 | body.value = ""; |
| 526 | form.hidden = true; |
| 527 | let opened = ""; |
| 528 | try { |
| 529 | const d = JSON.parse(res.data); |
| 530 | opened = d && d.number ? ` #${d.number}` : ""; |
| 531 | } catch {} |
| 532 | note.textContent = `Issue${opened} opened ✓`; |
| 533 | fetchIssues(openRepo); // pull the fresh list so it shows up |
| 534 | }; |
| 535 | row.append(cancel, submit); |
| 536 | form.append(title, body, row); |
| 537 | return form; |
| 538 | }; |
| 539 | |
| 540 | // ---- header + assembly, per view --------------------------------------- |
| 541 | const title = document.createElement("h2"); |
| 542 | title.className = "gh-title"; |
| 543 | const sub = document.createElement("span"); |
| 544 | sub.className = "gh-sub"; |
| 545 | |
| 546 | let drawList; |
| 547 | if (openRepo) { |
| 548 | const back = document.createElement("button"); |
| 549 | back.type = "button"; |
| 550 | back.className = "gh-btn"; |
| 551 | back.textContent = "‹ Repos"; |
| 552 | back.onclick = () => { |
| 553 | openRepo = null; |
| 554 | filterText = ""; |
| 555 | activeIndex = 0; |
| 556 | redraw(); |
| 557 | }; |
| 558 | title.textContent = openRepo; |
| 559 | const newIssue = document.createElement("button"); |
| 560 | newIssue.type = "button"; |
| 561 | newIssue.className = "gh-btn"; |
| 562 | newIssue.textContent = "+ New issue"; |
| 563 | const refresh = document.createElement("button"); |
| 564 | refresh.type = "button"; |
| 565 | refresh.className = "gh-btn"; |
| 566 | refresh.textContent = "↻"; |
| 567 | refresh.onclick = () => fetchIssues(openRepo); |
| 568 | head.append(back, title, sub, newIssue, refresh); |
| 569 | |
| 570 | const form = buildIssueForm(); |
| 571 | newIssue.onclick = () => { |
| 572 | form.hidden = !form.hidden; |
| 573 | if (!form.hidden) form.querySelector("input").focus(); |
| 574 | }; |
| 575 | el.append(form, filter, listOrEmpty, note); |
| 576 | |
| 577 | drawList = drawIssues; |
| 578 | } else { |
| 579 | title.textContent = "Your repositories"; |
| 580 | const refresh = document.createElement("button"); |
| 581 | refresh.type = "button"; |
| 582 | refresh.className = "gh-btn"; |
| 583 | refresh.textContent = "↻ Refresh"; |
| 584 | refresh.onclick = fetchRepos; |
| 585 | head.append(title, sub, refresh); |
| 586 | el.append(filter, listOrEmpty, note); |
| 587 | |
| 588 | drawList = drawRepos; |
| 589 | } |
| 590 | |
| 591 | // Transient bits (busy label, count line) live outside folded state. |
| 592 | const paint = () => { |
| 593 | sub.textContent = busy |
| 594 | ? "loading…" |
| 595 | : openRepo |
| 596 | ? (issues[openRepo] ? `${issues[openRepo].items.length} open` : "") |
| 597 | : repos.length |
| 598 | ? `${repos.length} repositor${repos.length === 1 ? "y" : "ies"}` |
| 599 | : ""; |
| 600 | }; |
| 601 | |
| 602 | drawList(); |
| 603 | paint(); |
| 604 | |
| 605 | // Auto-loads kick off ONLY after the surface is fully built — the |
| 606 | // fetchers call paint(), so firing them mid-render hit paint's temporal |
| 607 | // dead zone: a ReferenceError before the busy-reset, and the app wedged |
| 608 | // on "loading…" with every later Refresh silently swallowed. |
| 609 | if (openRepo) { |
| 610 | const entry = issues[openRepo]; |
| 611 | // Opened with nothing (or stale > 2 min) → pull once, automatically. |
| 612 | if (!busy && (!entry || Date.now() - (entry.fetchedAt || 0) > 120000)) fetchIssues(openRepo); |
| 613 | } else if (!state.fetchedAt && !busy && !autoLoading) { |
| 614 | autoLoading = true; |
| 615 | fetchRepos(); |
| 616 | } |
| 617 | }, |
| 618 | }); |
| 619 | |