P1 — cut showcases:
- Delete gallery.tsx (Primitives) and StatesScreen/Specimen from states.tsx
(keep the reusable EmptyState/OfflineBanner/ModelAwayState).
- Delete placeholder-screen.tsx ('built in a later phase' stub).
- app-shell: drop the states/primitives views, the dev-rail block, the
PHASE/TITLE maps, the INBOX_UNREAD=3 fixture fallback, and the now-dead demo state.
P2 — real connectivity:
- Replace the fake 'toggle the connection (demo)' button with a live status dot
derived from the reconcile: green online, red when serving the stale cache
(gitea unreachable), amber while connecting. OfflineBanner + chat offline now
reflect real state, not a manual toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
75 lines
2.8 KiB
TypeScript
75 lines
2.8 KiB
TypeScript
/**
|
|
* Token-scoped discovery for onboarding. Given just a base URL + PAT (no repo
|
|
* chosen yet), enumerate the owners and repositories the token can reach, so the
|
|
* connect screen can offer them as dropdowns instead of free-text. These
|
|
* endpoints are user-scoped, not repo-scoped, so they don't belong on the
|
|
* repo-bound `GiteaClient` — this is a standalone call with the same injected
|
|
* `fetch` seam (real fetch in main, stub in tests).
|
|
*/
|
|
import { GiteaApiError } from './types.js'
|
|
import type { FetchLike } from './types.js'
|
|
|
|
/** What onboarding needs to render the owner + repo pickers. */
|
|
export interface DiscoveredRepos {
|
|
/** The token's own login (its default owner). */
|
|
user: string
|
|
/** Owners with at least one accessible repo — the user first, then the rest, sorted. */
|
|
owners: string[]
|
|
/** Repo names per owner, sorted. Keys match `owners`. */
|
|
reposByOwner: Record<string, string[]>
|
|
}
|
|
|
|
interface RawRepo {
|
|
name: string
|
|
owner?: { login?: string } | null
|
|
}
|
|
|
|
const PAGE_LIMIT = 50
|
|
|
|
async function getJson(url: string, token: string, fetchImpl: FetchLike): Promise<unknown> {
|
|
const res = await fetchImpl(url, { headers: { Authorization: `token ${token}`, Accept: 'application/json' } })
|
|
if (!res.ok) {
|
|
const body = await res.text().catch(() => '')
|
|
throw new GiteaApiError(res.status, `GET ${url} failed (${res.status})`, body)
|
|
}
|
|
return res.json()
|
|
}
|
|
|
|
/**
|
|
* Enumerate the owners + repos a token can reach. Hits `/user` (to learn the
|
|
* token's own login and prove the token works) and paginates `/user/repos`
|
|
* (every repo the token can access across personal + org namespaces).
|
|
*/
|
|
export async function discoverRepos(conn: { baseUrl: string; token: string }, fetchImpl: FetchLike): Promise<DiscoveredRepos> {
|
|
const apiBase = `${conn.baseUrl.replace(/\/+$/, '')}/api/v1`
|
|
|
|
const me = (await getJson(`${apiBase}/user`, conn.token, fetchImpl)) as { login?: string }
|
|
const user = me.login ?? ''
|
|
|
|
const repos: RawRepo[] = []
|
|
for (let page = 1; ; page++) {
|
|
const batch = (await getJson(`${apiBase}/user/repos?page=${page}&limit=${PAGE_LIMIT}`, conn.token, fetchImpl)) as RawRepo[]
|
|
repos.push(...batch)
|
|
if (batch.length < PAGE_LIMIT) break
|
|
}
|
|
|
|
const reposByOwner: Record<string, string[]> = {}
|
|
for (const r of repos) {
|
|
const owner = r.owner?.login
|
|
if (!owner || !r.name) continue
|
|
;(reposByOwner[owner] ??= []).push(r.name)
|
|
}
|
|
for (const owner of Object.keys(reposByOwner)) {
|
|
reposByOwner[owner] = [...new Set(reposByOwner[owner])].sort((a, b) => a.localeCompare(b))
|
|
}
|
|
|
|
// owners sorted alphabetically, but the token's own login always leads.
|
|
const owners = Object.keys(reposByOwner).sort((a, b) => {
|
|
if (a === user) return -1
|
|
if (b === user) return 1
|
|
return a.localeCompare(b)
|
|
})
|
|
|
|
return { user, owners, reposByOwner }
|
|
}
|