A repository's pages fail one card at a time: each page still fails on what it is about (the pull request, the issue, the commit, the run, the file, the list), and every other read goes through part(), so a service that does not answer leaves Unavailable in that card's place, not a blank card or a crashed page. Pull request: the diff, Sessions, Decisions, What happened, the preview, the merge queue's findings, Labels, Milestone, Notifications and the reviewer and assignee pickers each say so in place (settings stay strict: unread, they would let checks be ignored). Issue: Labels, Milestone, Notifications, assignees, links from outside g1t, Work and Decisions. Commit: its pull request (a stream that could reject into an Await with no errorElement), comments and the issues it closed. Code, tree and file: branches (inside the branch menu), deployments, the fork's source, the About sidebar (Packages no longer says "No packages published" when it could not be read) and a file's CODEOWNERS check; environmentsFor and forkSourceFor no longer swallow failures. Actions: workflows; a run's artifacts and job summaries; a check run's annotations. Deployments: environments, history and g1t.page hosting each on their own, never "No deployments yet" or "members can turn on hosting" because of a failure; a deployment that cannot be read is a 503, not a 404. Work: Live now, the queue, What happened, Just landed. Lists: label and milestone filters, branch rows' pull requests and previews, activity (no longer "No activity yet"), fork descriptions, compare's branches and changes, new pull request's forks and a named fork. Overview: Needs you, deployments, observability. Boundaries around the README, About, the run graph, annotations, summaries, report cards and the sidebar boxes. Kept quiet on purpose, each a decoration or an extra the service checks again: teams for mentions, ended claims, merge choices (settings answer), job-by-job checks, watcher and tag counts, the star count, release suggestions, compute notes, the plan on Deployments. Checked: apps/web tsc -b; npm test (987, 1 skipped).
- G1t-Session:
- ses_01m4zzpc96ect8d41mffgr1a45
31 files+1082−4420/31 viewed
| 27 | 27 | import type { PackageSummary, Repo, RepoAbout } from "@g1t/contracts"; | |
| 28 | 28 | ||
| 29 | 29 | import { compact, count, languageBar, licenseLabel } from "../lib/about"; | |
| 30 | + | import { type Failed, isFailed } from "../lib/partial"; | |
| 31 | + | import { Unavailable } from "./boundary"; | |
| 30 | 32 | import { contributorPerson, profileHref, shownName } from "../lib/commit-people"; | |
| 31 | 33 | import { CommitAvatar, CommitName } from "./commit-person"; | |
| 32 | 34 | import { UserCard } from "./user-card"; | |
| ⋯ | |||
| 194 | 196 | ); | |
| 195 | 197 | } | |
| 196 | 198 | ||
| 197 | − | function Packages({ packages }: { packages: PackageSummary[] | null }) { | |
| 199 | + | function Packages({ packages: found }: { packages: PackageSummary[] | Failed | null }) { | |
| 200 | + | if (isFailed(found)) { | |
| 201 | + | return ( | |
| 202 | + | <Section title="Packages"> | |
| 203 | + | <Unavailable compact what={found.what} /> | |
| 204 | + | </Section> | |
| 205 | + | ); | |
| 206 | + | } | |
| 207 | + | const packages = found; | |
| 198 | 208 | return ( | |
| 199 | 209 | <Section title="Packages" count={packages?.length}> | |
| 200 | 210 | {packages && packages.length > 0 ? ( | |
| ⋯ | |||
| 356 | 366 | base: string; | |
| 357 | 367 | first: RepoAbout; | |
| 358 | 368 | canPush: boolean; | |
| 359 | − | packages: Promise<PackageSummary[] | null> | PackageSummary[] | null; | |
| 369 | + | packages: Promise<PackageSummary[] | Failed | null> | PackageSummary[] | Failed | null; | |
| 360 | 370 | tags: Promise<number | null> | number | null; | |
| 361 | 371 | deployments: ReactNode; | |
| 362 | 372 | }) { | |
| ⋯ | |||
| 369 | 379 | </Await> | |
| 370 | 380 | </Suspense> | |
| 371 | 381 | <Suspense fallback={<SectionSkeleton title="Packages" />}> | |
| 372 | − | <Await resolve={packages} errorElement={<Packages packages={null} />}> | |
| 382 | + | <Await resolve={packages} errorElement={<Packages packages={{ failed: true, what: "Packages" }} />}> | |
| 373 | 383 | {(found) => <Packages packages={found} />} | |
| 374 | 384 | </Await> | |
| 375 | 385 | </Suspense> | |
| ⋯ | |||
| 382 | 392 | ); | |
| 383 | 393 | } | |
| 384 | 394 | ||
| 395 | + | /** In place of the sections that come from the About, when it could not be read. */ | |
| 396 | + | function AboutUnavailable({ what }: { what: string }) { | |
| 397 | + | return ( | |
| 398 | + | <div className="border-t border-line pt-4"> | |
| 399 | + | <Unavailable compact what={what} /> | |
| 400 | + | </div> | |
| 401 | + | ); | |
| 402 | + | } | |
| 403 | + | ||
| 385 | 404 | function SectionSkeleton({ title }: { title: string }) { | |
| 386 | 405 | return ( | |
| 387 | 406 | <section className="border-t border-line pt-4" aria-busy="true"> | |
| ⋯ | |||
| 392 | 411 | } | |
| 393 | 412 | ||
| 394 | 413 | export type AboutData = { | |
| 395 | − | /** What the default branch says, stars and releases; null when it could not be read. */ | |
| 396 | − | about: Promise<RepoAbout | null> | RepoAbout | null; | |
| 414 | + | /** What the default branch says, stars and releases; null when there is none to read, Failed when it could not be read. */ | |
| 415 | + | about: Promise<RepoAbout | Failed | null> | RepoAbout | Failed | null; | |
| 397 | 416 | /** How many watch it; null when it could not be read. */ | |
| 398 | 417 | watchers: Promise<number | null> | number | null; | |
| 399 | − | /** Packages published from it; null when they could not be read. */ | |
| 400 | − | packages: Promise<PackageSummary[] | null> | PackageSummary[] | null; | |
| 418 | + | /** Packages published from it; null when there are none to read, Failed when they could not be read. */ | |
| 419 | + | packages: Promise<PackageSummary[] | Failed | null> | PackageSummary[] | Failed | null; | |
| 401 | 420 | /** How many tags it has; null when they could not be counted. */ | |
| 402 | 421 | tags?: Promise<number | null> | number | null; | |
| 403 | 422 | }; | |
| ⋯ | |||
| 433 | 452 | )} | |
| 434 | 453 | <Suspense fallback={<FactsSkeleton />}> | |
| 435 | 454 | <Await resolve={data.about} errorElement={facts(null, null)}> | |
| 436 | − | {(about) => ( | |
| 437 | − | <Suspense fallback={facts(about, null)}> | |
| 438 | − | <Await resolve={data.watchers} errorElement={facts(about, null)}> | |
| 439 | − | {(watchers) => facts(about, watchers)} | |
| 440 | − | </Await> | |
| 441 | − | </Suspense> | |
| 442 | − | )} | |
| 455 | + | {(found) => { | |
| 456 | + | // Without it, the lines it gives (license, stars) are left | |
| 457 | + | // out: the sections below say it could not be loaded. | |
| 458 | + | const about = isFailed(found) ? null : found; | |
| 459 | + | return ( | |
| 460 | + | <Suspense fallback={facts(about, null)}> | |
| 461 | + | <Await resolve={data.watchers} errorElement={facts(about, null)}> | |
| 462 | + | {(watchers) => facts(about, watchers)} | |
| 463 | + | </Await> | |
| 464 | + | </Suspense> | |
| 465 | + | ); | |
| 466 | + | }} | |
| 443 | 467 | </Await> | |
| 444 | 468 | </Suspense> | |
| 445 | 469 | </ul> | |
| ⋯ | |||
| 458 | 482 | </> | |
| 459 | 483 | } | |
| 460 | 484 | > | |
| 461 | − | <Await resolve={data.about} errorElement={<>{deployments}</>}> | |
| 485 | + | <Await | |
| 486 | + | resolve={data.about} | |
| 487 | + | errorElement={ | |
| 488 | + | <> | |
| 489 | + | <AboutUnavailable what="Releases, contributors and languages" /> | |
| 490 | + | {deployments} | |
| 491 | + | </> | |
| 492 | + | } | |
| 493 | + | > | |
| 462 | 494 | {(about) => | |
| 463 | − | about ? ( | |
| 495 | + | isFailed(about) ? ( | |
| 496 | + | // Releases, contributors and languages all come from it: one | |
| 497 | + | // line in their place says so, and the rest still shows. | |
| 498 | + | <> | |
| 499 | + | <AboutUnavailable what={about.what} /> | |
| 500 | + | {deployments} | |
| 501 | + | </> | |
| 502 | + | ) : about ? ( | |
| 464 | 503 | <LoadedSections base={base} first={about} canPush={canPush} packages={data.packages} tags={data.tags ?? null} deployments={deployments} /> | |
| 465 | 504 | ) : ( | |
| 466 | 505 | <>{deployments}</> | |
| 15 | 15 | import { CodeLines } from "./code-lines"; | |
| 16 | 16 | ||
| 17 | 17 | import { useAddresses } from "../lib/addresses"; | |
| 18 | + | import { type Failed, isFailed } from "../lib/partial"; | |
| 19 | + | import { Boundary, Unavailable } from "./boundary"; | |
| 18 | 20 | import { isImagePath } from "../lib/usercontent"; | |
| 19 | 21 | import { CloneBox } from "./clone-box"; | |
| 20 | 22 | import { type ChecksSource, CommitChecksBadge } from "./commit-checks"; | |
| ⋯ | |||
| 131 | 133 | gitRef, | |
| 132 | 134 | path, | |
| 133 | 135 | branches, | |
| 136 | + | failed = null, | |
| 134 | 137 | view = "tree", | |
| 135 | 138 | }: { | |
| 136 | 139 | base: string; | |
| 137 | 140 | gitRef: string; | |
| 138 | 141 | path: string; | |
| 139 | 142 | branches: Branch[]; | |
| 143 | + | /** The branch list could not be loaded: the menu says so under the one shown. */ | |
| 144 | + | failed?: Failed | null; | |
| 140 | 145 | /** Whether `path` is a folder or a file. */ | |
| 141 | 146 | view?: "tree" | "blob"; | |
| 142 | 147 | }) { | |
| ⋯ | |||
| 158 | 163 | </Link> | |
| 159 | 164 | </DropdownMenuItem> | |
| 160 | 165 | ))} | |
| 166 | + | {failed && ( | |
| 167 | + | <div className="px-2 py-1.5"> | |
| 168 | + | <Unavailable compact what={failed.what} /> | |
| 169 | + | </div> | |
| 170 | + | )} | |
| 161 | 171 | </DropdownMenuContent> | |
| 162 | 172 | </DropdownMenu> | |
| 163 | 173 | ); | |
| ⋯ | |||
| 226 | 236 | repo: { namespace: string; name: string }; | |
| 227 | 237 | gitRef: string; | |
| 228 | 238 | path: string; | |
| 229 | − | branches: Branch[] | null; | |
| 239 | + | branches: Branch[] | Failed | null; | |
| 230 | 240 | }) { | |
| 231 | 241 | const full = `${repo.namespace}/${repo.name}`; | |
| 232 | − | const list = branches && branches.length > 0 ? branches : [{ name: gitRef, hash: "" }]; | |
| 242 | + | const failed = isFailed(branches) ? branches : null; | |
| 243 | + | const known = isFailed(branches) ? null : branches; | |
| 244 | + | const list = known && known.length > 0 ? known : [{ name: gitRef, hash: "" }]; | |
| 233 | 245 | return ( | |
| 234 | 246 | <div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 235 | − | <BranchMenu base={base} gitRef={gitRef} path={path} branches={list} /> | |
| 247 | + | <BranchMenu base={base} gitRef={gitRef} path={path} branches={list} failed={failed} /> | |
| 236 | 248 | {path ? ( | |
| 237 | 249 | <Breadcrumbs base={base} repo={repo.name} gitRef={gitRef} path={path} /> | |
| 238 | 250 | ) : ( | |
| 239 | 251 | <> | |
| 240 | − | {branches && ( | |
| 252 | + | {known && ( | |
| 241 | 253 | <Link to={`${base}/branches`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent"> | |
| 242 | 254 | <GitBranch size={14} className="text-faint" /> | |
| 243 | − | <span className="font-medium text-fg">{branches.length}</span> | |
| 244 | − | {branches.length === 1 ? "branch" : "branches"} | |
| 255 | + | <span className="font-medium text-fg">{known.length}</span> | |
| 256 | + | {known.length === 1 ? "branch" : "branches"} | |
| 245 | 257 | </Link> | |
| 246 | 258 | )} | |
| 247 | 259 | <Link to={`${base}/tags`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent"> | |
| ⋯ | |||
| 341 | 353 | tree: Tree; | |
| 342 | 354 | /** Above the files of a fork: where it came from, Sync fork and Contribute (components/fork-bar.tsx). */ | |
| 343 | 355 | fork?: ReactNode; | |
| 344 | − | branches?: Branch[] | null; | |
| 356 | + | /** For the branch menu; Failed when it could not be loaded. */ | |
| 357 | + | branches?: Branch[] | Failed | null; | |
| 345 | 358 | /** Each entry's last commit, streamed in after the list. */ | |
| 346 | 359 | lastCommits?: Promise<FileCommits | null> | null; | |
| 347 | 360 | /** The latest commit's checks, streamed in. */ | |
| ⋯ | |||
| 350 | 363 | about?: AboutData | null; | |
| 351 | 364 | /** Whether the viewer may push, for the About's "Create a new release". */ | |
| 352 | 365 | canPush?: boolean; | |
| 353 | − | /** The repository's environments, for the About's Deployments section. */ | |
| 354 | − | deployments?: DeploymentEnvironments | null; | |
| 366 | + | /** The repository's environments, for the About's Deployments section; Failed when they could not be loaded. */ | |
| 367 | + | deployments?: DeploymentEnvironments | Failed | null; | |
| 355 | 368 | }) { | |
| 356 | 369 | const { repo, ref, path, head, entries, readme } = tree; | |
| 357 | 370 | const base = `/${repo.namespace}/${repo.name}`; | |
| ⋯ | |||
| 394 | 407 | </Card> | |
| 395 | 408 | ||
| 396 | 409 | {readme?.text != null && ( | |
| 397 | − | <Card asChild tone="plain" className="mt-6 scroll-mt-20 overflow-hidden"> | |
| 398 | − | <section id="readme"> | |
| 399 | − | <h2 className="flex items-center gap-2 border-b border-line bg-surface px-4 py-2.5 text-sm font-medium"> | |
| 400 | − | <BookOpen size={15} className="text-faint" /> | |
| 401 | − | {readme.name} | |
| 402 | − | </h2> | |
| 403 | − | <div className="p-4 sm:p-6"> | |
| 404 | − | {/\.(md|markdown)$/i.test(readme.name) ? ( | |
| 405 | − | <Markdown | |
| 406 | − | source={readme.text} | |
| 407 | − | repo={{ namespace: repo.namespace, name: repo.name }} | |
| 408 | − | // Relative links in a README point into the repository, | |
| 409 | − | // and its pictures at the files of the commit shown. | |
| 410 | − | base={`/${repo.namespace}/${repo.name}/blob/${ref}${path ? `/${path}` : ""}`} | |
| 411 | − | rawBase={`/${repo.namespace}/${repo.name}/raw/${head.hash}${path ? `/${encodePath(path)}` : ""}`} | |
| 412 | − | /> | |
| 413 | − | ) : ( | |
| 414 | − | <pre className="whitespace-pre-wrap text-sm"><code>{readme.text}</code></pre> | |
| 415 | − | )} | |
| 416 | − | </div> | |
| 417 | − | </section> | |
| 418 | − | </Card> | |
| 410 | + | // A README that fails to draw says so in its place; the files stay. | |
| 411 | + | <Boundary what="The README"> | |
| 412 | + | <Card asChild tone="plain" className="mt-6 scroll-mt-20 overflow-hidden"> | |
| 413 | + | <section id="readme"> | |
| 414 | + | <h2 className="flex items-center gap-2 border-b border-line bg-surface px-4 py-2.5 text-sm font-medium"> | |
| 415 | + | <BookOpen size={15} className="text-faint" /> | |
| 416 | + | {readme.name} | |
| 417 | + | </h2> | |
| 418 | + | <div className="p-4 sm:p-6"> | |
| 419 | + | {/\.(md|markdown)$/i.test(readme.name) ? ( | |
| 420 | + | <Markdown | |
| 421 | + | source={readme.text} | |
| 422 | + | repo={{ namespace: repo.namespace, name: repo.name }} | |
| 423 | + | // Relative links in a README point into the repository, | |
| 424 | + | // and its pictures at the files of the commit shown. | |
| 425 | + | base={`/${repo.namespace}/${repo.name}/blob/${ref}${path ? `/${path}` : ""}`} | |
| 426 | + | rawBase={`/${repo.namespace}/${repo.name}/raw/${head.hash}${path ? `/${encodePath(path)}` : ""}`} | |
| 427 | + | /> | |
| 428 | + | ) : ( | |
| 429 | + | <pre className="whitespace-pre-wrap text-sm"><code>{readme.text}</code></pre> | |
| 430 | + | )} | |
| 431 | + | </div> | |
| 432 | + | </section> | |
| 433 | + | </Card> | |
| 434 | + | </Boundary> | |
| 419 | 435 | )} | |
| 420 | 436 | </div> | |
| 421 | 437 | ||
| 422 | 438 | {!path && ( | |
| 423 | − | <RepoAboutPanel | |
| 424 | − | repo={repo} | |
| 425 | − | gitRef={ref} | |
| 426 | − | readme={Boolean(readme)} | |
| 427 | − | data={about ?? { about: null, watchers: null, packages: null, tags: null }} | |
| 428 | − | canPush={canPush} | |
| 429 | − | deployments={<DeploymentsPanel base={base} summary={deployments} className="border-t border-line pt-4" />} | |
| 430 | − | /> | |
| 439 | + | <Boundary what="About" fallback={<Unavailable compact what="About" />}> | |
| 440 | + | <RepoAboutPanel | |
| 441 | + | repo={repo} | |
| 442 | + | gitRef={ref} | |
| 443 | + | readme={Boolean(readme)} | |
| 444 | + | data={about ?? { about: null, watchers: null, packages: null, tags: null }} | |
| 445 | + | canPush={canPush} | |
| 446 | + | deployments={ | |
| 447 | + | isFailed(deployments) ? ( | |
| 448 | + | <section className="border-t border-line pt-4"> | |
| 449 | + | <h2 className="mb-2.5 text-sm font-semibold">Deployments</h2> | |
| 450 | + | <Unavailable compact what={deployments.what} /> | |
| 451 | + | </section> | |
| 452 | + | ) : ( | |
| 453 | + | <DeploymentsPanel base={base} summary={deployments} className="border-t border-line pt-4" /> | |
| 454 | + | ) | |
| 455 | + | } | |
| 456 | + | /> | |
| 457 | + | </Boundary> | |
| 431 | 458 | )} | |
| 432 | 459 | </div> | |
| 433 | 460 | ); | |
| ⋯ | |||
| 441 | 468 | notice, | |
| 442 | 469 | marked, | |
| 443 | 470 | }: { | |
| 444 | − | /** For the branch menu; it shows the current branch alone without them. */ | |
| 445 | − | branches?: Branch[] | null; | |
| 471 | + | /** For the branch menu; it shows the current branch alone without them, and says so when they failed. */ | |
| 472 | + | branches?: Branch[] | Failed | null; | |
| 446 | 473 | /** Said above the file, such as what is wrong with it. */ | |
| 447 | 474 | notice?: ReactNode; | |
| 448 | 475 | /** Lines to mark as having a problem, 1-based. */ | |
| ⋯ | |||
| 478 | 505 | base={base} | |
| 479 | 506 | gitRef={ref} | |
| 480 | 507 | path={path} | |
| 481 | − | branches={branches && branches.length > 0 ? branches : [{ name: ref, hash: "" }]} | |
| 508 | + | branches={!isFailed(branches) && branches && branches.length > 0 ? branches : [{ name: ref, hash: "" }]} | |
| 509 | + | failed={isFailed(branches) ? branches : null} | |
| 482 | 510 | view="blob" | |
| 483 | 511 | /> | |
| 484 | 512 | <Breadcrumbs base={base} repo={repo.name} gitRef={ref} path={path} /> | |
| 2 | 2 | * The Files page's About, streamed in after the files: what the default | |
| 3 | 3 | * branch says, stars and releases from repos; how many watch it from the | |
| 4 | 4 | * inbox; packages published from it; how many tags it has. Each is its own promise, so a slow | |
| 5 | − | * one holds up only its own section, and a failure shows the section | |
| 6 | − | * without it rather than failing the page. | |
| 5 | + | * one holds up only its own section, and a failure stays in its section | |
| 6 | + | * rather than failing the page: what the default branch says and the | |
| 7 | + | * packages say they could not be loaded (lib/partial.ts), and the counts | |
| 8 | + | * of watchers and tags, which only add to a line, are left out. | |
| 7 | 9 | */ | |
| 8 | 10 | import type { PackageSummary, RepoAbout, RepoPath, Viewer } from "@g1t/contracts"; | |
| 9 | 11 | ||
| 10 | 12 | import type { AboutData } from "../components/repo-about"; | |
| 13 | + | import { type Failed, part } from "./partial"; | |
| 11 | 14 | import { inbox, packages, repos } from "./services.server"; | |
| 12 | 15 | ||
| 13 | 16 | /** How long a section waits before it shows without its answer. */ | |
| ⋯ | |||
| 17 | 20 | return Promise.race([promise.catch(() => null), new Promise<null>((resolve) => setTimeout(() => resolve(null), WAIT_MS))]); | |
| 18 | 21 | } | |
| 19 | 22 | ||
| 23 | + | /** Like `within`, but one that fails or does not answer in time is `Failed`, for its section to say so. */ | |
| 24 | + | function partWithin<T>(promise: Promise<T>, what: string): Promise<T | Failed> { | |
| 25 | + | const late = new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`no answer in ${WAIT_MS} ms`)), WAIT_MS)); | |
| 26 | + | return part(Promise.race([promise, late]), what); | |
| 27 | + | } | |
| 28 | + | ||
| 20 | 29 | export function aboutOf(path: RepoPath, viewer: Viewer): Promise<RepoAbout | null> { | |
| 21 | 30 | return repos.about(path, viewer).then((found) => (found.ok ? found.value : null)); | |
| 22 | 31 | } | |
| 23 | 32 | ||
| 24 | 33 | export function aboutFor(path: RepoPath, viewer: Viewer, repoId: string): AboutData { | |
| 25 | 34 | return { | |
| 26 | − | about: within(aboutOf(path, viewer)), | |
| 35 | + | about: partWithin(aboutOf(path, viewer), "Releases, contributors and languages"), | |
| 27 | 36 | watchers: within(inbox.watchers(repoId)), | |
| 28 | − | packages: within( | |
| 37 | + | packages: partWithin( | |
| 29 | 38 | packages.list(path.namespace, viewer, { repoId }).then((found): PackageSummary[] | null => (found.ok ? found.value : null)), | |
| 39 | + | "Packages", | |
| 30 | 40 | ), | |
| 31 | 41 | tags: within(repos.tags(path, viewer).then((found) => (found.ok ? found.value.length : null))), | |
| 32 | 42 | }; | |
| 4 | 4 | ||
| 5 | 5 | /** | |
| 6 | 6 | * A repository's environments for a sidebar's Deployments panel, or null | |
| 7 | − | * when there are none or they cannot be read just now: the page shows | |
| 8 | − | * without the panel rather than failing. | |
| 7 | + | * when there are none. It throws when they cannot be read just now: the | |
| 8 | + | * page reads it as a part (lib/partial.ts), so the panel says so in its | |
| 9 | + | * place rather than going missing. | |
| 9 | 10 | */ | |
| 10 | 11 | export async function environmentsFor(repo: RepoPath, viewer: Viewer): Promise<DeploymentEnvironments | null> { | |
| 11 | − | try { | |
| 12 | − | const found = await deployments.environments(repo, viewer); | |
| 13 | − | return found.ok && found.value.environments.length > 0 ? found.value : null; | |
| 14 | − | } catch (error) { | |
| 15 | − | console.warn("deployments:", error); | |
| 16 | − | return null; | |
| 17 | − | } | |
| 12 | + | const found = await deployments.environments(repo, viewer); | |
| 13 | + | return found.ok && found.value.environments.length > 0 ? found.value : null; | |
| 18 | 14 | } |
| 7 | 7 | /** | |
| 8 | 8 | * The repository the one at `params` was forked from, for its fork bar | |
| 9 | 9 | * (components/fork-bar.tsx), when it is a fork whose parent the viewer can | |
| 10 | − | * read; null otherwise, or when either cannot be read just now. | |
| 10 | + | * read; null otherwise. It throws when either cannot be read just now: the | |
| 11 | + | * page reads it as a part (lib/partial.ts), so the bar's place says so. | |
| 11 | 12 | */ | |
| 12 | 13 | export async function forkSourceFor( | |
| 13 | 14 | context: Parameters<typeof repoFor>[0], | |
| 14 | 15 | params: { owner?: string; repo?: string }, | |
| 15 | 16 | viewer: Viewer, | |
| 16 | 17 | ): Promise<ForkSource | null> { | |
| 17 | − | try { | |
| 18 | − | const found = await repoFor(context, params); | |
| 19 | − | if (!found.ok || !found.value.parent) return null; | |
| 20 | − | const parent = await repos.get(found.value.parent, viewer); | |
| 21 | − | if (!parent.ok) return null; | |
| 22 | − | return { | |
| 23 | − | namespace: parent.value.namespace, | |
| 24 | − | name: parent.value.name, | |
| 25 | − | defaultBranch: parent.value.defaultBranch, | |
| 26 | − | forkDefault: found.value.defaultBranch, | |
| 27 | − | }; | |
| 28 | − | } catch { | |
| 29 | − | return null; | |
| 30 | − | } | |
| 18 | + | const found = await repoFor(context, params); | |
| 19 | + | if (!found.ok || !found.value.parent) return null; | |
| 20 | + | const parent = await repos.get(found.value.parent, viewer); | |
| 21 | + | if (!parent.ok) return null; | |
| 22 | + | return { | |
| 23 | + | namespace: parent.value.namespace, | |
| 24 | + | name: parent.value.name, | |
| 25 | + | defaultBranch: parent.value.defaultBranch, | |
| 26 | + | forkDefault: found.value.defaultBranch, | |
| 27 | + | }; | |
| 31 | 28 | } |
| 36 | 36 | ||
| 37 | 37 | import type { Route } from "./+types/actions-run"; | |
| 38 | 38 | import { page } from "../../lib/meta"; | |
| 39 | + | import { isFailed, part } from "../../lib/partial"; | |
| 40 | + | import { Boundary, Unavailable } from "../../components/boundary"; | |
| 39 | 41 | import { Duration, LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog, useNow } from "../../components/actions"; | |
| 40 | 42 | import { Markdown } from "../../components/markdown"; | |
| 41 | 43 | import { RunGraph } from "../../components/run-graph"; | |
| ⋯ | |||
| 73 | 75 | // An earlier attempt, when one is asked for. | |
| 74 | 76 | const asked = Number(new URL(request.url).searchParams.get("attempt") ?? ""); | |
| 75 | 77 | const attempt = Number.isInteger(asked) && asked > 0 ? asked : undefined; | |
| 78 | + | // The run is what the page is about; what it kept and its jobs' summaries | |
| 79 | + | // are parts, each said in its own place when it cannot be read. | |
| 76 | 80 | const [detail, kept, summaries] = await Promise.all([ | |
| 77 | 81 | actions.run(repo, viewer, params.id, attempt).then(unwrap), | |
| 78 | − | listArtifacts(repo, viewer, params.id).catch(() => []), | |
| 79 | − | actions | |
| 80 | − | .summaries(repo, viewer, params.id, attempt) | |
| 81 | − | .then((found) => (found.ok ? found.value : [])) | |
| 82 | − | .catch((): JobSummary[] => []), | |
| 82 | + | part(listArtifacts(repo, viewer, params.id), "Artifacts"), | |
| 83 | + | part( | |
| 84 | + | actions.summaries(repo, viewer, params.id, attempt).then((found): JobSummary[] => (found.ok ? found.value : [])), | |
| 85 | + | "Job summaries", | |
| 86 | + | ), | |
| 83 | 87 | ]); | |
| 84 | 88 | // Artifacts an older runner kept in KV: asked for only once the service | |
| 85 | 89 | // has shown the viewer the run, and never while it is still going. | |
| 86 | − | const artifacts = legacyArtifactsWorthAsking(detail.run) ? await withLegacyArtifacts(kept, params.id) : kept; | |
| 90 | + | const artifacts = | |
| 91 | + | !isFailed(kept) && legacyArtifactsWorthAsking(detail.run) ? await withLegacyArtifacts(kept, params.id) : kept; | |
| 87 | 92 | // Cancelling and re-running need Write. | |
| 88 | 93 | return { | |
| 89 | 94 | detail, | |
| ⋯ | |||
| 986 | 991 | </div> | |
| 987 | 992 | <div className="min-w-0 space-y-1.5"> | |
| 988 | 993 | <p className={label}>Artifacts</p> | |
| 989 | − | <p className="font-medium"> | |
| 990 | − | {artifacts.length > 0 ? ( | |
| 991 | − | <a href="#artifacts" className="text-accent hover:underline"> | |
| 992 | − | {artifacts.length} | |
| 993 | − | </a> | |
| 994 | − | ) : ( | |
| 995 | − | "—" | |
| 996 | − | )} | |
| 997 | − | </p> | |
| 994 | + | {isFailed(artifacts) ? ( | |
| 995 | + | <Unavailable what="Artifacts" compact /> | |
| 996 | + | ) : ( | |
| 997 | + | <p className="font-medium"> | |
| 998 | + | {artifacts.length > 0 ? ( | |
| 999 | + | <a href="#artifacts" className="text-accent hover:underline"> | |
| 1000 | + | {artifacts.length} | |
| 1001 | + | </a> | |
| 1002 | + | ) : ( | |
| 1003 | + | "—" | |
| 1004 | + | )} | |
| 1005 | + | </p> | |
| 1006 | + | )} | |
| 998 | 1007 | </div> | |
| 999 | 1008 | </div> | |
| 1000 | 1009 | </section> | |
| ⋯ | |||
| 1063 | 1072 | base: string; | |
| 1064 | 1073 | runId: string; | |
| 1065 | 1074 | }) { | |
| 1075 | + | if (isFailed(artifacts)) { | |
| 1076 | + | // Its heading stays, so the run's count links somewhere that says why. | |
| 1077 | + | return ( | |
| 1078 | + | <Card asChild className="scroll-mt-20 p-4"> | |
| 1079 | + | <section id="artifacts"> | |
| 1080 | + | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| 1081 | + | <Package size={14} className="text-muted" /> | |
| 1082 | + | Artifacts | |
| 1083 | + | </h3> | |
| 1084 | + | <div className="mt-3"> | |
| 1085 | + | <Unavailable what="Artifacts" compact /> | |
| 1086 | + | </div> | |
| 1087 | + | </section> | |
| 1088 | + | </Card> | |
| 1089 | + | ); | |
| 1090 | + | } | |
| 1066 | 1091 | if (artifacts.length === 0) return null; | |
| 1067 | 1092 | return ( | |
| 1068 | 1093 | <Card asChild className="scroll-mt-20 p-4"> | |
| ⋯ | |||
| 1132 | 1157 | }: { | |
| 1133 | 1158 | detail: RunData["detail"]; | |
| 1134 | 1159 | artifacts: RunData["artifacts"]; | |
| 1135 | − | summaries: JobSummary[]; | |
| 1160 | + | summaries: RunData["summaries"]; | |
| 1136 | 1161 | standing: string; | |
| 1137 | 1162 | cancelling: boolean; | |
| 1138 | 1163 | member: boolean; | |
| ⋯ | |||
| 1142 | 1167 | }) { | |
| 1143 | 1168 | const { run, jobs } = detail; | |
| 1144 | 1169 | const [namespace = "", name = ""] = base.slice(1).split("/"); | |
| 1170 | + | // Each card keeps a failure to itself: the run's card and its logs stay. | |
| 1145 | 1171 | return ( | |
| 1146 | 1172 | <section aria-label="Summary" className="min-w-0 space-y-4"> | |
| 1147 | 1173 | <RunCard detail={detail} artifacts={artifacts} standing={standing} cancelling={cancelling} base={base} /> | |
| 1148 | − | <RunGraph jobs={jobs} title={run.path.split("/").pop() || run.name} trigger={run.event} href={jobHref} /> | |
| 1149 | − | <AnnotationsPanel jobs={jobs} jobHref={jobHref} /> | |
| 1150 | − | <Summaries summaries={summaries} repo={{ namespace, name }} jobHref={jobHref} /> | |
| 1174 | + | <Boundary what="The jobs' graph"> | |
| 1175 | + | <RunGraph jobs={jobs} title={run.path.split("/").pop() || run.name} trigger={run.event} href={jobHref} /> | |
| 1176 | + | </Boundary> | |
| 1177 | + | <Boundary what="Annotations"> | |
| 1178 | + | <AnnotationsPanel jobs={jobs} jobHref={jobHref} /> | |
| 1179 | + | </Boundary> | |
| 1180 | + | {isFailed(summaries) ? ( | |
| 1181 | + | <Unavailable what="Job summaries" /> | |
| 1182 | + | ) : ( | |
| 1183 | + | <Boundary what="Job summaries"> | |
| 1184 | + | <Summaries summaries={summaries} repo={{ namespace, name }} jobHref={jobHref} /> | |
| 1185 | + | </Boundary> | |
| 1186 | + | )} | |
| 1151 | 1187 | <ArtifactsList artifacts={artifacts} member={member} busy={busy} base={base} runId={run.id} /> | |
| 1152 | 1188 | </section> | |
| 1153 | 1189 | ); | |
| 19 | 19 | import { badgeMarkdown, badgeUrl } from "../../lib/badge"; | |
| 20 | 20 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 21 | 21 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 22 | + | import { isFailed, part } from "../../lib/partial"; | |
| 23 | + | import { Unavailable } from "../../components/boundary"; | |
| 22 | 24 | import { actions } from "../../lib/services.server"; | |
| 23 | 25 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 24 | 26 | import { accessFor, refusal, repoFor } from "../../lib/access.server"; | |
| ⋯ | |||
| 40 | 42 | const member = can?.run ?? false; | |
| 41 | 43 | // Who may open the pull request that adds a starter workflow. | |
| 42 | 44 | const canPush = can?.push ?? false; | |
| 45 | + | // The runs are what the page is about; the list of workflows beside them | |
| 46 | + | // is a part, which says so in its place when it cannot be read. | |
| 43 | 47 | const [workflows, runs, computeNote] = await Promise.all([ | |
| 44 | − | actions.workflows(repo, viewer), | |
| 48 | + | part(actions.workflows(repo, viewer), "Workflows"), | |
| 45 | 49 | actions.runs(repo, viewer, { workflow: selected, limit: 50 }), | |
| 46 | 50 | // Jobs run on g1t's machines as the workspace's plan allows, or from | |
| 47 | 51 | // the open-source pool on a public repository; members are told | |
| 48 | − | // before a run is refused for it. | |
| 52 | + | // before a run is refused for it. A note that cannot be read is left | |
| 53 | + | // out: it only warns ahead of a refusal that says the same itself. | |
| 49 | 54 | member && found.ok | |
| 50 | 55 | ? computeNoteFor(params.owner, "workflow", !found.value.isPrivate).catch(() => null) | |
| 51 | 56 | : Promise.resolve(null), | |
| 52 | 57 | ]); | |
| 53 | 58 | return { | |
| 54 | − | workflows: unwrap(workflows), | |
| 59 | + | workflows: isFailed(workflows) ? workflows : unwrap(workflows), | |
| 55 | 60 | runs: runs.ok ? runs.value : [], | |
| 56 | 61 | member, | |
| 57 | 62 | canPush, | |
| ⋯ | |||
| 386 | 391 | - run: npm test`; | |
| 387 | 392 | ||
| 388 | 393 | export default function Actions({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 389 | − | const { workflows, runs, member, canPush, manage, computeNote, selected } = loaderData; | |
| 394 | + | const { runs, member, canPush, manage, computeNote, selected } = loaderData; | |
| 390 | 395 | const [search] = useSearchParams(); | |
| 391 | 396 | const base = `/${params.owner}/${params.repo}`; | |
| 397 | + | // Workflows that could not be read leave the runs, with a note in the list's place. | |
| 398 | + | const workflowsFailed = isFailed(loaderData.workflows); | |
| 399 | + | const workflows = isFailed(loaderData.workflows) ? [] : loaderData.workflows; | |
| 392 | 400 | const workflow = workflows.find((w) => w.id === selected || w.path.endsWith(`/${selected}`)) ?? null; | |
| 393 | 401 | useLiveWhile(runs.some((run) => run.status !== "completed")); | |
| 394 | 402 | const started = actionData && "started" in actionData ? actionData.started : null; | |
| ⋯ | |||
| 417 | 425 | </div> | |
| 418 | 426 | )} | |
| 419 | 427 | ||
| 420 | − | {workflows.length === 0 ? ( | |
| 428 | + | {!workflowsFailed && workflows.length === 0 ? ( | |
| 421 | 429 | <div className="grid gap-6 lg:grid-cols-2"> | |
| 422 | 430 | <div className="lg:col-span-2"> | |
| 423 | 431 | <AddCiPrompt owner={params.owner} repo={params.repo} canAdd={canPush} /> | |
| ⋯ | |||
| 442 | 450 | > | |
| 443 | 451 | All workflows | |
| 444 | 452 | </Link> | |
| 453 | + | {workflowsFailed && ( | |
| 454 | + | <div className="px-2.5 py-1.5"> | |
| 455 | + | <Unavailable what="Workflows" compact /> | |
| 456 | + | </div> | |
| 457 | + | )} | |
| 445 | 458 | {workflows.map((w) => ( | |
| 446 | 459 | <Link | |
| 447 | 460 | key={w.id} | |
| 12 | 12 | import { requireRepo } from "../../lib/access.server"; | |
| 13 | 13 | import { ACTIVITY_TYPES, type ActivityLine, aboutBranch, activityLine } from "../../lib/about"; | |
| 14 | 14 | import { page } from "../../lib/meta"; | |
| 15 | + | import { isFailed, part } from "../../lib/partial"; | |
| 16 | + | import { Unavailable } from "../../components/boundary"; | |
| 15 | 17 | import { events, identity } from "../../lib/services.server"; | |
| 16 | 18 | import { UserAvatar, UserCard } from "../../components/user-card"; | |
| 17 | 19 | ||
| ⋯ | |||
| 28 | 30 | // One branch's activity (the Branches page links here): each page of | |
| 29 | 31 | // events is read whole and only that branch's lines are kept. | |
| 30 | 32 | const only = url.searchParams.get("branch")?.trim() || null; | |
| 31 | − | const found = await events | |
| 32 | − | .list({ repoId: repo.id, types: [...ACTIVITY_TYPES] as EventType[], before, limit: PAGE }) | |
| 33 | − | .catch(() => []); | |
| 33 | + | // The list says when it could not be loaded, rather than reading as no | |
| 34 | + | // activity at all. | |
| 35 | + | const read = await part(events.list({ repoId: repo.id, types: [...ACTIVITY_TYPES] as EventType[], before, limit: PAGE }), "Activity"); | |
| 36 | + | if (isFailed(read)) return { failed: read, lines: [], namesFailed: null, next: null, defaultBranch: repo.defaultBranch, branch: only }; | |
| 37 | + | const found = read; | |
| 34 | 38 | const lines = found.flatMap((event) => { | |
| 35 | 39 | const line = activityLine(event); | |
| 36 | 40 | return line && (!only || aboutBranch(line, only, repo.defaultBranch)) ? [{ id: event.id, time: event.time, actor: event.actor, line }] : []; | |
| ⋯ | |||
| 38 | 42 | // Who: people by their name now; g1t as itself, and as a workspace | |
| 39 | 43 | // acting under its own identity (lib/mission.ts), never named after it. | |
| 40 | 44 | const ids = [...new Set(lines.map((line) => line.actor).filter((actor): actor is string => !!actor && actor !== "g1t"))]; | |
| 41 | − | const names = ids.length > 0 ? await identity.usernames(ids.filter((id) => !id.startsWith("wsp_"))).catch(() => ({}) as Record<string, string>) : {}; | |
| 45 | + | const named = ids.length > 0 ? await part(identity.usernames(ids.filter((id) => !id.startsWith("wsp_"))), "Who did each") : {}; | |
| 46 | + | // Without them each line still shows, by "Someone", and the list says why. | |
| 47 | + | const names: Record<string, string> = isFailed(named) ? {} : named; | |
| 42 | 48 | const g1t = (actor: string | null) => actor === "g1t" || !!actor?.startsWith("wsp_"); | |
| 43 | 49 | return { | |
| 50 | + | failed: null, | |
| 51 | + | namesFailed: isFailed(named) ? named : null, | |
| 44 | 52 | lines: lines.map((line) => ({ ...line, who: g1t(line.actor) ? "g1t" : line.actor ? (names[line.actor] ?? null) : null })), | |
| 45 | 53 | next: found.length === PAGE ? found[found.length - 1].id : null, | |
| 46 | 54 | defaultBranch: repo.defaultBranch, | |
| ⋯ | |||
| 110 | 118 | } | |
| 111 | 119 | ||
| 112 | 120 | export default function ActivityPage({ loaderData, params }: Route.ComponentProps) { | |
| 113 | − | const { lines, next, branch } = loaderData; | |
| 121 | + | const { lines, next, branch, failed, namesFailed } = loaderData; | |
| 114 | 122 | const base = `/${params.owner}/${params.repo}`; | |
| 115 | 123 | return ( | |
| 116 | 124 | <div className="space-y-4"> | |
| ⋯ | |||
| 131 | 139 | )} | |
| 132 | 140 | </p> | |
| 133 | 141 | </div> | |
| 134 | − | {lines.length === 0 ? ( | |
| 142 | + | {namesFailed && <Unavailable compact what={namesFailed.what} />} | |
| 143 | + | {failed ? ( | |
| 144 | + | <Unavailable what={failed.what} /> | |
| 145 | + | ) : lines.length === 0 ? ( | |
| 135 | 146 | <EmptyState title={branch ? (next ? "Nothing on this branch here" : "No activity on this branch") : "No activity yet"}> | |
| 136 | 147 | {branch && next ? "Nothing in this stretch of the repository's activity was about it. Look further back." : "Pushes and merges show here as they happen."} | |
| 137 | 148 | </EmptyState> | |
| 10 | 10 | import { repos, work } from "../../lib/services.server"; | |
| 11 | 11 | import { redirectIfBranchRenamed } from "../../lib/branch-redirect.server"; | |
| 12 | 12 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 13 | + | import { isFailed, part } from "../../lib/partial"; | |
| 14 | + | import { Unavailable } from "../../components/boundary"; | |
| 13 | 15 | ||
| 14 | 16 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 15 | 17 | const tags = page(args, { title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 25 | 27 | const wantsBlame = new URL(request.url).searchParams.has("blame"); | |
| 26 | 28 | const [found, list, checked] = await Promise.all([ | |
| 27 | 29 | repos.blob(path, viewer, params.ref, file), | |
| 28 | − | // For the branch menu; the page still shows without it. | |
| 29 | − | repos.branches(path, viewer).catch(() => null), | |
| 30 | − | // A CODEOWNERS file is checked as it is shown; the file shows without it. | |
| 31 | − | isCodeownersPath(file) ? work.codeownersErrors(path, viewer, params.ref).catch(() => null) : null, | |
| 30 | + | // For the branch menu; the page still shows without it, and the menu | |
| 31 | + | // says when it could not be loaded. | |
| 32 | + | part(repos.branches(path, viewer), "Branches"), | |
| 33 | + | // A CODEOWNERS file is checked as it is shown; the file shows without | |
| 34 | + | // it, saying it could not be checked rather than looking clean. | |
| 35 | + | isCodeownersPath(file) ? part(work.codeownersErrors(path, viewer, params.ref), "This file's check") : null, | |
| 32 | 36 | ]); | |
| 33 | − | const codeowners = checked?.ok ? checked.value : null; | |
| 34 | − | const branches = list?.ok ? list.value : null; | |
| 37 | + | const codeowners = isFailed(checked) ? checked : checked?.ok ? checked.value : null; | |
| 38 | + | const branches = isFailed(list) ? list : list.ok ? list.value : null; | |
| 35 | 39 | // A branch that was renamed: the same file on its new name. | |
| 36 | 40 | if (!found.ok && found.error.code === "not_found") await redirectIfBranchRenamed(request, path, viewer, params.ref); | |
| 37 | 41 | const blob = unwrap(found); | |
| ⋯ | |||
| 45 | 49 | export default function Blob({ loaderData, params }: Route.ComponentProps) { | |
| 46 | 50 | const { codeowners, blob, html, wantsBlame } = loaderData; | |
| 47 | 51 | // Its errors' lines are marked, when they are this file's. | |
| 48 | − | const own = codeowners != null && codeowners.path === blob.path; | |
| 52 | + | const own = codeowners != null && !isFailed(codeowners) && codeowners.path === blob.path; | |
| 49 | 53 | const blame = useBlame(wantsBlame ? `/${params.owner}/${params.repo}/blame/${encodeURIComponent(blob.ref)}/${encodePath(blob.path)}` : null); | |
| 50 | 54 | return ( | |
| 51 | 55 | <BlobView | |
| ⋯ | |||
| 54 | 58 | blame={blame === null ? null : typeof blame === "string" ? blame : { blame, lines: html }} | |
| 55 | 59 | branches={loaderData.branches} | |
| 56 | 60 | notice={ | |
| 57 | − | codeowners && ( | |
| 58 | − | <CodeownersFileErrors report={codeowners} path={blob.path} base={`/${params.owner}/${params.repo}`} /> | |
| 61 | + | isFailed(codeowners) ? ( | |
| 62 | + | <div className="mb-4"> | |
| 63 | + | <Unavailable compact what={codeowners.what} /> | |
| 64 | + | </div> | |
| 65 | + | ) : ( | |
| 66 | + | codeowners && <CodeownersFileErrors report={codeowners} path={blob.path} base={`/${params.owner}/${params.repo}`} /> | |
| 59 | 67 | ) | |
| 60 | 68 | } | |
| 61 | 69 | marked={own ? codeowners.errors.map((error) => error.line).filter((line) => line > 0) : undefined} | |
| 26 | 26 | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 27 | 27 | import { commitChecksFor } from "../../lib/commit-checks.server"; | |
| 28 | 28 | import { page } from "../../lib/meta"; | |
| 29 | + | import { isFailed, orElse, part } from "../../lib/partial"; | |
| 30 | + | import { Unavailable } from "../../components/boundary"; | |
| 29 | 31 | import { deployments, repos, work } from "../../lib/services.server"; | |
| 30 | 32 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| 31 | 33 | ||
| ⋯ | |||
| 49 | 51 | const { viewer, repo, access } = await requireRepo(context, params, "read"); | |
| 50 | 52 | const path = { namespace: params.owner, name: params.repo }; | |
| 51 | 53 | const query = url.searchParams.get("q")?.trim().toLowerCase() || null; | |
| 52 | − | const soft = <T,>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null); | |
| 53 | 54 | // Admins are told when the default branch has no rules at all. | |
| 54 | 55 | const canProtect = access.can.manage_protection && !repo.archivedAt; | |
| 55 | 56 | const [list, open, closed, deploys, rules] = await Promise.all([ | |
| 56 | 57 | repos.branches(path, viewer), | |
| 57 | − | soft(work.listPulls(path, viewer, "open")), | |
| 58 | + | part(work.listPulls(path, viewer, "open"), "Open pull requests"), | |
| 58 | 59 | // Merged and closed ones, for branches with none open. | |
| 59 | − | soft(work.listPulls(path, viewer, "closed")), | |
| 60 | + | part(work.listPulls(path, viewer, "closed"), "Merged and closed pull requests"), | |
| 60 | 61 | // Previews are for people with a role here; everyone else sees none. | |
| 61 | − | soft(deployments.list({ workspace: params.owner, slug: params.repo }, viewer)), | |
| 62 | − | canProtect && !repo.protected ? soft(work.effectiveRules(path, repo.defaultBranch, viewer)) : Promise.resolve(null), | |
| 62 | + | part(deployments.list({ workspace: params.owner, slug: params.repo }, viewer), "Previews"), | |
| 63 | + | // Only a hint for admins: without the rules, it is not given. | |
| 64 | + | canProtect && !repo.protected ? part(work.effectiveRules(path, repo.defaultBranch, viewer), "Rules").then((found) => orElse(found, null)) : Promise.resolve(null), | |
| 63 | 65 | ]); | |
| 64 | 66 | // The merge queue's own branches (g1t-queue/<entry>) are its working | |
| 65 | 67 | // copies, not anyone's branch to look at. | |
| ⋯ | |||
| 71 | 73 | { | |
| 72 | 74 | defaultBranch: repo.defaultBranch, | |
| 73 | 75 | branches: matching, | |
| 74 | − | pulls: [...(open?.ok ? open.value : []), ...(closed?.ok ? closed.value : [])], | |
| 75 | − | previews: deploys?.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [], | |
| 76 | + | pulls: [...(!isFailed(open) && open.ok ? open.value : []), ...(!isFailed(closed) && closed.ok ? closed.value : [])], | |
| 77 | + | previews: !isFailed(deploys) && deploys.ok ? deploys.value.live.filter((app) => app.kind === "preview") : [], | |
| 76 | 78 | }, | |
| 77 | 79 | BRANCHES_READ, | |
| 78 | 80 | ); | |
| ⋯ | |||
| 101 | 103 | }, | |
| 102 | 104 | // No rules hold for it, and it is not protected the older way. | |
| 103 | 105 | unprotected: rules?.ok === true && rules.value.rulesets.length === 0, | |
| 106 | + | // What each row would show beside its branch but could not be loaded. | |
| 107 | + | missing: [open, closed, deploys].filter(isFailed).map((failed) => failed.what), | |
| 104 | 108 | }; | |
| 105 | 109 | } | |
| 106 | 110 | ||
| ⋯ | |||
| 137 | 141 | } | |
| 138 | 142 | ||
| 139 | 143 | export default function Branches({ loaderData, params }: Route.ComponentProps) { | |
| 140 | − | const { tab, query, main, branches, total, matched, unread, names, checks, username, now, can, unprotected } = loaderData; | |
| 144 | + | const { tab, query, main, branches, total, matched, unread, names, checks, username, now, can, unprotected, missing } = loaderData; | |
| 141 | 145 | const base = `/${params.owner}/${params.repo}`; | |
| 142 | 146 | const navigate = useNavigate(); | |
| 143 | 147 | // New, moved and deleted branches show as they happen (lib/use-repo-live.ts). | |
| ⋯ | |||
| 230 | 234 | </p> | |
| 231 | 235 | )} | |
| 232 | 236 | ||
| 237 | + | {/* The branches show without them; each row's pull request or preview would be missing without a word. */} | |
| 238 | + | {missing.length > 0 && ( | |
| 239 | + | <div className="space-y-1"> | |
| 240 | + | {missing.map((what) => ( | |
| 241 | + | <Unavailable key={what} compact what={what} /> | |
| 242 | + | ))} | |
| 243 | + | </div> | |
| 244 | + | )} | |
| 245 | + | ||
| 233 | 246 | {tab === "overview" && ( | |
| 234 | 247 | <section aria-labelledby="branches-default" className="space-y-2"> | |
| 235 | 248 | <h3 id="branches-default" className="text-sm font-medium text-muted"> | |
| 5 | 5 | import type { AnnotationLevel, CheckAnnotation, CommitCheckRun } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/check-run"; | |
| 8 | + | import { Boundary, Unavailable } from "../../components/boundary"; | |
| 8 | 9 | import { CheckStateIcon } from "../../components/commit-checks"; | |
| 9 | 10 | import { Markdown } from "../../components/markdown"; | |
| 10 | 11 | import { EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| ⋯ | |||
| 14 | 15 | import { checkDetail, took } from "../../lib/commit-checks"; | |
| 15 | 16 | import { formatLineHash } from "../../lib/line-anchor"; | |
| 16 | 17 | import { page } from "../../lib/meta"; | |
| 18 | + | import { isFailed, part } from "../../lib/partial"; | |
| 17 | 19 | import { work } from "../../lib/services.server"; | |
| 18 | 20 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 19 | 21 | ||
| ⋯ | |||
| 27 | 29 | const path = { namespace: params.owner, name: params.repo }; | |
| 28 | 30 | const [run, annotations, access] = await Promise.all([ | |
| 29 | 31 | work.getCheckRun(path, params.id, viewer), | |
| 30 | − | work.checkRunAnnotations(path, params.id, viewer), | |
| 32 | + | // The check run is what the page is about; its annotations are a part. | |
| 33 | + | part(work.checkRunAnnotations(path, params.id, viewer), "Annotations"), | |
| 31 | 34 | accessTo(context, params), | |
| 32 | 35 | ]); | |
| 33 | 36 | const found = unwrap(run); | |
| ⋯ | |||
| 35 | 38 | if (found.workflow) throw redirect(found.htmlUrl); | |
| 36 | 39 | return { | |
| 37 | 40 | run: found, | |
| 38 | − | annotations: annotations.ok ? annotations.value : [], | |
| 41 | + | annotations: isFailed(annotations) ? annotations : annotations.ok ? annotations.value : [], | |
| 39 | 42 | // Pressing its buttons and asking for it again: Write and up. | |
| 40 | 43 | canReport: access.can.push, | |
| 41 | 44 | }; | |
| ⋯ | |||
| 234 | 237 | </Card> | |
| 235 | 238 | ||
| 236 | 239 | {title || summary || text ? ( | |
| 237 | − | <Card asChild tone="plain" className="p-4 sm:p-6"> | |
| 238 | − | <section> | |
| 239 | − | {title && <h3 className="mb-3 text-lg font-semibold">{title}</h3>} | |
| 240 | − | {summary && <Markdown source={summary} repo={{ namespace: params.owner, name: params.repo }} />} | |
| 241 | − | {text && ( | |
| 242 | − | <div className={summary ? "mt-6 border-t border-line pt-6" : ""}> | |
| 243 | − | <Markdown source={text} repo={{ namespace: params.owner, name: params.repo }} /> | |
| 244 | − | </div> | |
| 245 | − | )} | |
| 246 | − | </section> | |
| 247 | − | </Card> | |
| 240 | + | // The report is whatever the app wrote: one that will not draw leaves the run. | |
| 241 | + | <Boundary what="The report"> | |
| 242 | + | <Card asChild tone="plain" className="p-4 sm:p-6"> | |
| 243 | + | <section> | |
| 244 | + | {title && <h3 className="mb-3 text-lg font-semibold">{title}</h3>} | |
| 245 | + | {summary && <Markdown source={summary} repo={{ namespace: params.owner, name: params.repo }} />} | |
| 246 | + | {text && ( | |
| 247 | + | <div className={summary ? "mt-6 border-t border-line pt-6" : ""}> | |
| 248 | + | <Markdown source={text} repo={{ namespace: params.owner, name: params.repo }} /> | |
| 249 | + | </div> | |
| 250 | + | )} | |
| 251 | + | </section> | |
| 252 | + | </Card> | |
| 253 | + | </Boundary> | |
| 248 | 254 | ) : ( | |
| 249 | 255 | <EmptyState title={run.status === "completed" ? "No report" : "No report yet"}> | |
| 250 | 256 | {run.app.name} {run.status === "completed" ? "said nothing more about this run." : "has not said anything more yet."} | |
| 251 | 257 | </EmptyState> | |
| 252 | 258 | )} | |
| 253 | 259 | ||
| 254 | − | {annotations.length > 0 && <Annotations run={run} annotations={annotations} base={base} />} | |
| 260 | + | {isFailed(annotations) ? ( | |
| 261 | + | <section className="space-y-3"> | |
| 262 | + | <h3 className="text-base font-semibold">Annotations</h3> | |
| 263 | + | <Unavailable what="Annotations" /> | |
| 264 | + | </section> | |
| 265 | + | ) : ( | |
| 266 | + | annotations.length > 0 && ( | |
| 267 | + | <Boundary what="Annotations"> | |
| 268 | + | <Annotations run={run} annotations={annotations} base={base} /> | |
| 269 | + | </Boundary> | |
| 270 | + | ) | |
| 271 | + | )} | |
| 255 | 272 | </div> | |
| 256 | 273 | ); | |
| 257 | 274 | } | |
| 13 | 13 | import { forkSourceFor } from "../../lib/forks.server"; | |
| 14 | 14 | import { repoPages } from "../../lib/repo-live"; | |
| 15 | 15 | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 16 | + | import { isFailed, part } from "../../lib/partial"; | |
| 17 | + | import { Unavailable } from "../../components/boundary"; | |
| 16 | 18 | ||
| 17 | 19 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 18 | 20 | return page(args, { title: `Code · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 23 | 25 | const viewer = getViewer(context); | |
| 24 | 26 | const [tree, branches, environments, fork] = await Promise.all([ | |
| 25 | 27 | repos.tree(path, viewer, null, ""), | |
| 26 | − | // For the branch menu; the page still shows without it. | |
| 27 | − | repos.branches(path, viewer).catch(() => null), | |
| 28 | + | // For the branch menu; the page still shows without it, and the menu | |
| 29 | + | // says when it could not be loaded. | |
| 30 | + | part(repos.branches(path, viewer), "Branches"), | |
| 28 | 31 | // For the About sidebar's Deployments panel; null without any. | |
| 29 | − | environmentsFor(path, viewer), | |
| 32 | + | part(environmentsFor(path, viewer), "Deployments"), | |
| 30 | 33 | // A fork's parent, for the bar above its files. | |
| 31 | − | forkSourceFor(context, params, viewer), | |
| 34 | + | part(forkSourceFor(context, params, viewer), "Where this fork came from"), | |
| 32 | 35 | ]); | |
| 33 | 36 | // Each entry's last commit walks history: streamed in after the list. | |
| 34 | 37 | const lastCommits = lastCommitsFor(path, viewer, null, ""); | |
| ⋯ | |||
| 39 | 42 | const about = value.head ? aboutFor(path, viewer, value.repo.id) : null; | |
| 40 | 43 | // The latest commit's checks: streamed in beside it. | |
| 41 | 44 | const checks = commitChecksFor(path, viewer, [value.head?.hash]); | |
| 42 | − | return { tree: value, branches: branches?.ok ? branches.value : null, lastCommits, about, deployments: environments, checks, fork }; | |
| 45 | + | return { | |
| 46 | + | tree: value, | |
| 47 | + | branches: isFailed(branches) ? branches : branches.ok ? branches.value : null, | |
| 48 | + | lastCommits, | |
| 49 | + | about, | |
| 50 | + | deployments: environments, | |
| 51 | + | checks, | |
| 52 | + | fork, | |
| 53 | + | }; | |
| 43 | 54 | } | |
| 44 | 55 | ||
| 45 | 56 | export default function Code({ loaderData }: Route.ComponentProps) { | |
| 46 | 57 | const project = useProject(); | |
| 47 | 58 | // The files and latest commit follow pushes to the branch; the latest | |
| 48 | 59 | // commit's checks update as they report. | |
| 49 | − | const { tree, branches } = loaderData; | |
| 60 | + | const { tree, fork } = loaderData; | |
| 61 | + | // Without the branch list, the page follows its branch as if it were one. | |
| 62 | + | const branches = isFailed(loaderData.branches) ? null : loaderData.branches; | |
| 50 | 63 | useRepoLive({ match: repoPages.files({ branch: tree.ref, branches: branches?.map((branch) => branch.name), sha: tree.head?.hash }) }); | |
| 51 | 64 | return ( | |
| 52 | 65 | <TreeView | |
| ⋯ | |||
| 58 | 71 | canPush={Boolean(project?.access.can.push)} | |
| 59 | 72 | deployments={loaderData.deployments} | |
| 60 | 73 | fork={ | |
| 61 | − | loaderData.fork && project && branches?.some((branch) => branch.name === tree.ref) ? ( | |
| 74 | + | isFailed(fork) ? ( | |
| 75 | + | <div className="mb-4"> | |
| 76 | + | <Unavailable compact what={fork.what} /> | |
| 77 | + | </div> | |
| 78 | + | ) : fork && project && branches?.some((branch) => branch.name === tree.ref) ? ( | |
| 62 | 79 | <ForkBar | |
| 63 | 80 | base={`/${tree.repo.namespace}/${tree.repo.name}`} | |
| 64 | 81 | branch={tree.ref} | |
| 65 | − | parent={loaderData.fork} | |
| 82 | + | parent={fork} | |
| 66 | 83 | canPush={Boolean(project.access.can.push)} | |
| 67 | 84 | /> | |
| 68 | 85 | ) : null | |
| 28 | 28 | import { repos, work } from "../../lib/services.server"; | |
| 29 | 29 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 30 | 30 | import { accessTo } from "../../lib/access.server"; | |
| 31 | + | import { Unavailable } from "../../components/boundary"; | |
| 32 | + | import { isFailed, part } from "../../lib/partial"; | |
| 31 | 33 | ||
| 32 | 34 | export function headers({ loaderHeaders }: Route.HeadersArgs) { | |
| 33 | 35 | return { "Server-Timing": loaderHeaders.get("Server-Timing") ?? "" }; | |
| ⋯ | |||
| 57 | 59 | } | |
| 58 | 60 | const started = Date.now(); | |
| 59 | 61 | // Its comments, whether more may be added and the issues it closed: never | |
| 60 | − | // cached, read beside the commit. | |
| 62 | + | // cached, read beside the commit. Its comments and the issues it closed | |
| 63 | + | // are parts of the page that say so when they fail; without the settings | |
| 64 | + | // or the viewer's role, comments stay open and nobody moderates, which | |
| 65 | + | // the service checks again when one is sent. | |
| 61 | 66 | const said = Promise.all([ | |
| 62 | − | work.commitComments(viewer, path, hash).catch(() => null), | |
| 67 | + | part(work.commitComments(viewer, path, hash), "Comments"), | |
| 63 | 68 | work.getSettings(path, viewer).catch(() => null), | |
| 64 | 69 | accessTo(context, params).catch(() => null), | |
| 65 | − | work.closedByCommit(path, hash, viewer).catch(() => null), | |
| 70 | + | part(work.closedByCommit(path, hash, viewer), "The issues it closed"), | |
| 66 | 71 | ]); | |
| 67 | 72 | const found = unwrap(await repos.get(path, viewer)); | |
| 68 | 73 | // The viewer may read the repository, and a commit never changes: after | |
| ⋯ | |||
| 95 | 100 | comparison: firstScreens(loaded.comparison), | |
| 96 | 101 | commit, | |
| 97 | 102 | // Streamed: the page shows the commit while this is worked out. | |
| 98 | − | pull: pullForCommit(path, viewer, loaded.commit.hash), | |
| 103 | + | pull: part(pullForCommit(path, viewer, loaded.commit.hash), "The pull request it came in"), | |
| 99 | 104 | // Never cached: checks report on a commit long after it was made. | |
| 100 | 105 | checks: commitChecksFor(path, viewer, [loaded.commit.hash]), | |
| 101 | − | comments: comments?.ok ? comments.value : [], | |
| 106 | + | comments: isFailed(comments) ? comments : comments.ok ? comments.value : [], | |
| 102 | 107 | // Off, the comments made stay readable and no more can be added. | |
| 103 | 108 | commentsOn: settings?.ok ? settings.value.allowCommitComments : true, | |
| 104 | 109 | // The issues its message closed (`Closes #12`) when it was pushed. | |
| 105 | − | closed: closed?.ok ? closed.value : [], | |
| 110 | + | closed: isFailed(closed) ? closed : closed.ok ? closed.value : [], | |
| 106 | 111 | viewer: viewer ? { username: viewer.username } : null, | |
| 107 | 112 | // Maintain and up edit and delete anyone's comments; everyone, their own. | |
| 108 | 113 | canModerate: access?.can.manage_settings ?? false, | |
| ⋯ | |||
| 200 | 205 | ||
| 201 | 206 | export default function CommitPage({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 202 | 207 | const { commit, pull, comparison, checks, comments, commentsOn, closed, viewer, canModerate } = loaderData; | |
| 203 | − | const said = asComments(comments); | |
| 208 | + | // Comments that could not be read count as none; the list says so in its place. | |
| 209 | + | const said = asComments(isFailed(comments) ? [] : comments); | |
| 204 | 210 | const general = said.filter((comment) => !comment.path); | |
| 205 | 211 | const base = `/${params.owner}/${params.repo}`; | |
| 206 | 212 | const { subject, body, trailers } = parseCommitMessage(commit.message); | |
| ⋯ | |||
| 281 | 287 | </div> | |
| 282 | 288 | } | |
| 283 | 289 | > | |
| 284 | − | <Await resolve={pull}>{(pull) => (pull ? <MergedIn base={base} pull={pull} /> : null)}</Await> | |
| 290 | + | <Await resolve={pull}> | |
| 291 | + | {(pull) => | |
| 292 | + | isFailed(pull) ? ( | |
| 293 | + | <div className="border-t border-line px-5 py-3"> | |
| 294 | + | <Unavailable what={pull.what} compact /> | |
| 295 | + | </div> | |
| 296 | + | ) : pull ? ( | |
| 297 | + | <MergedIn base={base} pull={pull} /> | |
| 298 | + | ) : null | |
| 299 | + | } | |
| 300 | + | </Await> | |
| 285 | 301 | </Suspense> | |
| 286 | − | {closed.map((issue) => ( | |
| 287 | − | <ClosedIssueRow key={issue.number} base={base} issue={issue} /> | |
| 288 | − | ))} | |
| 302 | + | {isFailed(closed) ? ( | |
| 303 | + | <div className="border-t border-line px-5 py-3"> | |
| 304 | + | <Unavailable what={closed.what} compact /> | |
| 305 | + | </div> | |
| 306 | + | ) : ( | |
| 307 | + | closed.map((issue) => <ClosedIssueRow key={issue.number} base={base} issue={issue} />) | |
| 308 | + | )} | |
| 289 | 309 | </section> | |
| 290 | 310 | </Card> | |
| 291 | 311 | <div className="mt-6"> | |
| ⋯ | |||
| 307 | 327 | </span> | |
| 308 | 328 | )} | |
| 309 | 329 | </h3> | |
| 330 | + | {isFailed(comments) && <Unavailable what={comments.what} />} | |
| 310 | 331 | <CommentList | |
| 311 | 332 | comments={general} | |
| 312 | 333 | base={base} | |
| 22 | 22 | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 23 | 23 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 24 | 24 | import { useProject } from "./layout"; | |
| 25 | + | import { Unavailable } from "../../components/boundary"; | |
| 26 | + | import { isFailed, part } from "../../lib/partial"; | |
| 25 | 27 | ||
| 26 | 28 | const PAGE_SIZE = 50; | |
| 27 | 29 | ||
| ⋯ | |||
| 37 | 39 | const checks = commitChecksFor(path, viewer, log.map((commit) => commit.hash)); | |
| 38 | 40 | // Who wrote each commit, as their account where there is one. | |
| 39 | 41 | const commits = await showCommits(log); | |
| 40 | − | // How many comments each has, from the repository's latest: streamed. | |
| 41 | − | const comments = work | |
| 42 | − | .commitComments(viewer, path, null) | |
| 43 | − | .then((found) => (found.ok ? countByCommit(found.value) : {})) | |
| 44 | − | .catch(() => ({}) as Record<string, number>); | |
| 42 | + | // How many comments each has, from the repository's latest: streamed, and | |
| 43 | + | // a part, so counts that could not be read are not shown as none. | |
| 44 | + | const comments = part( | |
| 45 | + | work.commitComments(viewer, path, null).then((found): Record<string, number> => (found.ok ? countByCommit(found.value) : {})), | |
| 46 | + | "Comment counts", | |
| 47 | + | ); | |
| 45 | 48 | return { commits, checks, comments }; | |
| 46 | 49 | } | |
| 47 | 50 | ||
| ⋯ | |||
| 98 | 101 | return ( | |
| 99 | 102 | <> | |
| 100 | 103 | <NewItemsBar held={held.length > 0} count={newCommits(held, branch)} noun={["commit", "commits"]} onShow={show} /> | |
| 104 | + | <Suspense fallback={null}> | |
| 105 | + | <Await resolve={comments}> | |
| 106 | + | {(counts) => (isFailed(counts) ? <div className="mb-4"><Unavailable what={counts.what} compact /></div> : null)} | |
| 107 | + | </Await> | |
| 108 | + | </Suspense> | |
| 101 | 109 | <div className="relative space-y-8 before:absolute before:top-3 before:bottom-3 before:left-1.75 before:w-px before:bg-line"> | |
| 102 | 110 | {byDay(commits).map(([day, list]) => ( | |
| 103 | 111 | <section key={day} className="relative pl-7"> | |
| ⋯ | |||
| 124 | 132 | <CommitChecksBadge checks={checks} sha={commit.hash} /> | |
| 125 | 133 | <Suspense fallback={null}> | |
| 126 | 134 | <Await resolve={comments}> | |
| 127 | − | {(counts) => <CommentCount count={counts[commit.hash] ?? 0} to={`${to}#commit-comments`} />} | |
| 135 | + | {(counts) => (isFailed(counts) ? null : <CommentCount count={counts[commit.hash] ?? 0} to={`${to}#commit-comments`} />)} | |
| 128 | 136 | </Await> | |
| 129 | 137 | </Suspense> | |
| 130 | 138 | </div> | |
| 15 | 15 | import { showCommits } from "../../lib/commit-people.server"; | |
| 16 | 16 | import { repos } from "../../lib/services.server"; | |
| 17 | 17 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 18 | + | import { Unavailable } from "../../components/boundary"; | |
| 19 | + | import { type Failed, isFailed, part } from "../../lib/partial"; | |
| 18 | 20 | ||
| 19 | 21 | /** How far back each side's history is read to find what head has that base does not. */ | |
| 20 | 22 | const HEAD_DEPTH = 100; | |
| ⋯ | |||
| 42 | 44 | const head = url.searchParams.get("head") ?? ""; | |
| 43 | 45 | return Response.redirect(new URL(`/${params.owner}/${params.repo}/compare/${base}...${head}`, url).toString(), 302); | |
| 44 | 46 | } | |
| 45 | − | const [repo, list] = await Promise.all([repos.get(path, viewer), repos.branches(path, viewer)]); | |
| 47 | + | // The branches to pick from are a part: without them, the pickers offer | |
| 48 | + | // the two being compared and say the rest could not be read. | |
| 49 | + | const [repo, list] = await Promise.all([repos.get(path, viewer), part(repos.branches(path, viewer), "Branches")]); | |
| 46 | 50 | const found = unwrap(repo); | |
| 47 | − | const branches = unwrap(list) | |
| 48 | − | .map((branch) => branch.name) | |
| 49 | − | .filter((name) => !name.startsWith("g1t-queue/")); | |
| 51 | + | const branches: string[] | Failed = isFailed(list) | |
| 52 | + | ? list | |
| 53 | + | : unwrap(list) | |
| 54 | + | .map((branch) => branch.name) | |
| 55 | + | .filter((name) => !name.startsWith("g1t-queue/")); | |
| 50 | 56 | const asked = sides(params["*"] ?? ""); | |
| 51 | 57 | const base = asked.base ?? found.defaultBranch; | |
| 52 | 58 | const head = asked.head; | |
| ⋯ | |||
| 68 | 74 | // bring them: what base gained since is not head taking it away. When | |
| 69 | 75 | // that point is past what was read, the two tips are compared. | |
| 70 | 76 | const mergeBase = shared === -1 ? base : headLog.value[shared]!.hash; | |
| 71 | − | const compared = await repos.compare(found.id, viewer, mergeBase, head); | |
| 77 | + | // The changes are a part: the commits above them stay when they fail. | |
| 78 | + | const compared = await part(repos.compare(found.id, viewer, mergeBase, head), "The changes"); | |
| 72 | 79 | // Its first screens with their lines; the rest load as they are reached. | |
| 73 | − | const comparison: HighlightedComparison | null = compared.ok ? firstScreens(compared.value) : null; | |
| 80 | + | const comparison: HighlightedComparison | Failed | null = isFailed(compared) | |
| 81 | + | ? compared | |
| 82 | + | : compared.ok | |
| 83 | + | ? firstScreens(compared.value) | |
| 84 | + | : null; | |
| 74 | 85 | return { base, head, branches, comparison, commits, same: false }; | |
| 75 | 86 | } | |
| 76 | 87 | ||
| ⋯ | |||
| 98 | 109 | } | |
| 99 | 110 | ||
| 100 | 111 | export default function Compare({ loaderData, params }: Route.ComponentProps) { | |
| 101 | − | const { base, head, branches, comparison, commits, same } = loaderData; | |
| 112 | + | const { base, head, comparison, commits, same } = loaderData; | |
| 113 | + | const branches = isFailed(loaderData.branches) ? [] : loaderData.branches; | |
| 102 | 114 | const repoBase = `/${params.owner}/${params.repo}`; | |
| 103 | 115 | return ( | |
| 104 | 116 | <div className="space-y-5"> | |
| ⋯ | |||
| 129 | 141 | )} | |
| 130 | 142 | </Form> | |
| 131 | 143 | </Card> | |
| 144 | + | {isFailed(loaderData.branches) && <Unavailable what={loaderData.branches.what} compact />} | |
| 132 | 145 | ||
| 133 | 146 | {!head ? ( | |
| 134 | 147 | <EmptyState title="Pick a branch to compare">Its commits and changes against {base} show here.</EmptyState> | |
| ⋯ | |||
| 167 | 180 | </ul> | |
| 168 | 181 | </Card> | |
| 169 | 182 | </section> | |
| 170 | − | {comparison ? ( | |
| 183 | + | {isFailed(comparison) ? ( | |
| 184 | + | <Unavailable what={comparison.what} /> | |
| 185 | + | ) : comparison ? ( | |
| 171 | 186 | <DiffView comparison={comparison} repo={repoBase} fileBase={`${repoBase}/blob/${encodeURIComponent(head)}`} empty="The two sides have the same files." /> | |
| 172 | 187 | ) : ( | |
| 173 | 188 | <p className="text-sm text-muted">The changes could not be read just now. The commits above are what {head} adds.</p> | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/deployment"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | + | import { isFailed, part } from "../../lib/partial"; | |
| 10 | + | import { Boundary, Unavailable } from "../../components/boundary"; | |
| 9 | 11 | import { TimeAgo } from "../../components/ui"; | |
| 10 | 12 | import { Badge } from "../../components/ui/badge"; | |
| 11 | 13 | import { Card } from "../../components/ui/card"; | |
| ⋯ | |||
| 24 | 26 | const viewer = getViewer(context); | |
| 25 | 27 | const { repo } = await requireRepo(context, params, "read"); | |
| 26 | 28 | const path = { namespace: repo.namespace, name: repo.name }; | |
| 27 | − | const soft = <T,>(promise: Promise<T>): Promise<T | null> => | |
| 28 | − | promise.catch((error) => (console.warn("deployment:", error), null)); | |
| 29 | 29 | // A g1t.page build also has its build log and warnings, for the workspace's members. | |
| 30 | 30 | const [found, build] = await Promise.all([ | |
| 31 | − | soft(deployments.repoDeployment(path, params.id, viewer)), | |
| 32 | − | isPageBuild(params.id) ? soft(deployments.get({ workspace: params.owner, slug: params.repo }, params.id, viewer)) : null, | |
| 31 | + | part(deployments.repoDeployment(path, params.id, viewer), "Its deployment record"), | |
| 32 | + | isPageBuild(params.id) ? part(deployments.get({ workspace: params.owner, slug: params.repo }, params.id, viewer), "The build") : null, | |
| 33 | 33 | ]); | |
| 34 | − | const deployment = found?.ok ? found.value : null; | |
| 35 | − | const pageBuild = build?.ok ? build.value : null; | |
| 36 | − | if (!deployment && !pageBuild) throw data(null, { status: 404 }); | |
| 37 | − | return { deployment, build: pageBuild }; | |
| 34 | + | const deployment = !isFailed(found) && found.ok ? found.value : null; | |
| 35 | + | const pageBuild = build && !isFailed(build) && build.ok ? build.value : null; | |
| 36 | + | if (!deployment && !pageBuild) { | |
| 37 | + | // Either one heads the page. With neither, a read that failed is not | |
| 38 | + | // the deployment missing: say it could not be loaded rather than 404. | |
| 39 | + | if (isFailed(found) || isFailed(build)) { | |
| 40 | + | throw data("The deployment could not be loaded just now. Try again in a moment.", { status: 503 }); | |
| 41 | + | } | |
| 42 | + | throw data(null, { status: 404 }); | |
| 43 | + | } | |
| 44 | + | return { | |
| 45 | + | deployment, | |
| 46 | + | // A failed record under a build is said beside the build's heading; a | |
| 47 | + | // failed build under a record, in its details' place. | |
| 48 | + | recordFailed: isFailed(found), | |
| 49 | + | build: isFailed(build) ? build : pageBuild, | |
| 50 | + | }; | |
| 38 | 51 | } | |
| 39 | 52 | ||
| 40 | 53 | /** A g1t.page build's status in words. */ | |
| ⋯ | |||
| 49 | 62 | } as const; | |
| 50 | 63 | ||
| 51 | 64 | export default function DeploymentPage({ loaderData, params }: Route.ComponentProps) { | |
| 52 | − | const { deployment, build } = loaderData; | |
| 65 | + | const { deployment, recordFailed } = loaderData; | |
| 66 | + | const build = isFailed(loaderData.build) ? null : loaderData.build; | |
| 53 | 67 | const base = `/${params.owner}/${params.repo}`; | |
| 54 | 68 | return ( | |
| 55 | 69 | <div> | |
| ⋯ | |||
| 58 | 72 | Deployments | |
| 59 | 73 | </Link> | |
| 60 | 74 | {deployment ? <Reported deployment={deployment} base={base} /> : build && <BuildHeading build={build} base={base} />} | |
| 61 | − | {build && <BuildDetails build={build} />} | |
| 75 | + | {!deployment && recordFailed && ( | |
| 76 | + | <div className="mt-3"> | |
| 77 | + | <Unavailable what="Its deployment record" compact /> | |
| 78 | + | </div> | |
| 79 | + | )} | |
| 80 | + | {isFailed(loaderData.build) ? ( | |
| 81 | + | <section className="mt-8"> | |
| 82 | + | <h2 className="text-sm font-semibold">Build on g1t.page</h2> | |
| 83 | + | <div className="mt-3"> | |
| 84 | + | <Unavailable what="The build and its log" /> | |
| 85 | + | </div> | |
| 86 | + | </section> | |
| 87 | + | ) : ( | |
| 88 | + | build && ( | |
| 89 | + | <Boundary what="The build"> | |
| 90 | + | <BuildDetails build={build} /> | |
| 91 | + | </Boundary> | |
| 92 | + | ) | |
| 93 | + | )} | |
| 62 | 94 | </div> | |
| 63 | 95 | ); | |
| 64 | 96 | } | |
| 19 | 19 | import { DeployLink, host } from "../../components/deploy"; | |
| 20 | 20 | import { DeploymentStateBadge, DeploymentStateIcon, sourceLabel } from "../../components/deployments-panel"; | |
| 21 | 21 | import { FilterMenu } from "../../components/labels"; | |
| 22 | + | import { Boundary, Unavailable } from "../../components/boundary"; | |
| 22 | 23 | ||
| 23 | 24 | import type { Route } from "./+types/deployments"; | |
| 24 | 25 | import { page } from "../../lib/meta"; | |
| ⋯ | |||
| 27 | 28 | import { Card } from "../../components/ui/card"; | |
| 28 | 29 | import { Hint } from "../../components/ui/hint"; | |
| 29 | 30 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 31 | + | import { type Failed, isFailed, orElse, part } from "../../lib/partial"; | |
| 30 | 32 | import { neverDeploys } from "../../lib/project-kind"; | |
| 31 | 33 | import { billing, deployments, projects } from "../../lib/services.server"; | |
| 32 | 34 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| ⋯ | |||
| 62 | 64 | const path = { namespace: repo.namespace, name: repo.name }; | |
| 63 | 65 | const ref = { workspace: params.owner, slug: params.repo }; | |
| 64 | 66 | const filter = parseFilter(new URL(request.url).searchParams); | |
| 65 | − | // A part whose service fails shows as missing; the page stays up. | |
| 66 | − | const soft = <T,>(promise: Promise<T>): Promise<T | null> => | |
| 67 | − | promise.catch((error) => (console.warn("deployments:", error), null)); | |
| 67 | + | // Each part loads on its own, and one whose service fails says so in its | |
| 68 | + | // place while the rest of the page stays up. | |
| 68 | 69 | // g1t.page's settings and builds are the workspace's members' alone. | |
| 69 | 70 | const insider = access.insider; | |
| 70 | 71 | const [environments, list, settings, builds, features, computeNote, project] = await Promise.all([ | |
| 71 | − | soft(deployments.environments(path, viewer)), | |
| 72 | − | soft(deployments.repoDeployments(path, viewer, filter)), | |
| 73 | − | insider ? soft(deployments.settings(ref, viewer)) : null, | |
| 74 | − | insider ? soft(deployments.list(ref, viewer)) : null, | |
| 75 | − | insider ? soft(billing.features(params.owner, viewer)) : null, | |
| 72 | + | part(deployments.environments(path, viewer), "Environments"), | |
| 73 | + | part(deployments.repoDeployments(path, viewer, filter), "Deployment history"), | |
| 74 | + | insider ? part(deployments.settings(ref, viewer), "g1t.page hosting") : null, | |
| 75 | + | insider ? part(deployments.list(ref, viewer), "g1t.page hosting") : null, | |
| 76 | + | insider ? part(billing.features(params.owner, viewer), "The plan") : null, | |
| 76 | 77 | // Builds are compute: said before a deploy is refused for it. | |
| 77 | − | insider ? soft(computeNoteFor(params.owner, "deploy")) : null, | |
| 78 | − | soft(projects.get(params.owner, params.repo, viewer)), | |
| 78 | + | insider ? part(computeNoteFor(params.owner, "deploy"), "Compute") : null, | |
| 79 | + | part(projects.get(params.owner, params.repo, viewer), "The project"), | |
| 79 | 80 | ]); | |
| 80 | − | // Set not to deploy, in General settings: turning them on waits for that to change. | |
| 81 | − | const notDeploying = project?.ok ? neverDeploys(project.value) : false; | |
| 81 | + | // Set not to deploy, in General settings: turning them on waits for that | |
| 82 | + | // to change. Unread, it is left out: the deployments service refuses to | |
| 83 | + | // turn on what the project says never deploys. | |
| 84 | + | const known = orElse(project, null); | |
| 85 | + | const notDeploying = known?.ok ? neverDeploys(known.value) : false; | |
| 82 | 86 | // Deployments come with the g1t plan. Someone outside the workspace does | |
| 83 | − | // not see its plans; the page works without, and the deployments service | |
| 84 | − | // refuses a deploy the plan does not cover. | |
| 85 | − | const plan = features?.ok | |
| 86 | − | ? (features.value.find((state) => state.plan.feature === "plan" || state.plan.feature === "deployments") ?? null) | |
| 87 | + | // not see its plans; the page works without (as it does when the plans | |
| 88 | + | // cannot be read), and the deployments service refuses a deploy the plan | |
| 89 | + | // does not cover. | |
| 90 | + | const plans = orElse(features, null); | |
| 91 | + | const plan = plans?.ok | |
| 92 | + | ? (plans.value.find((state) => state.plan.feature === "plan" || state.plan.feature === "deployments") ?? null) | |
| 87 | 93 | : null; | |
| 94 | + | // g1t.page hosting, for the workspace's members: null when it is not | |
| 95 | + | // theirs to see, Failed when it could not be read. | |
| 96 | + | const pages = isFailed(settings) | |
| 97 | + | ? settings | |
| 98 | + | : isFailed(builds) | |
| 99 | + | ? builds | |
| 100 | + | : settings?.ok && builds?.ok | |
| 101 | + | ? // A note that cannot be read is left out, as on Workflows: the refusal says the same. | |
| 102 | + | { settings: settings.value, live: builds.value.live, plan, computeNote: orElse(computeNote, null), notDeploying } | |
| 103 | + | : null; | |
| 88 | 104 | return { | |
| 89 | 105 | can: access.can, | |
| 90 | 106 | filter, | |
| 91 | − | environments: environments?.ok ? { ...environments.value, environments: orderEnvironments(environments.value.environments) } : null, | |
| 92 | − | list: list?.ok ? list.value : null, | |
| 93 | − | // g1t.page hosting, for the workspace's members: null when it could not be read. | |
| 94 | − | pages: | |
| 95 | − | settings?.ok && builds?.ok | |
| 96 | − | ? { settings: settings.value, live: builds.value.live, plan, computeNote: computeNote ?? null, notDeploying } | |
| 107 | + | environments: isFailed(environments) | |
| 108 | + | ? environments | |
| 109 | + | : environments.ok | |
| 110 | + | ? { ...environments.value, environments: orderEnvironments(environments.value.environments) } | |
| 97 | 111 | : null, | |
| 112 | + | list: isFailed(list) ? list : list.ok ? list.value : null, | |
| 113 | + | pages, | |
| 98 | 114 | }; | |
| 99 | 115 | } | |
| 100 | 116 | ||
| ⋯ | |||
| 126 | 142 | } | |
| 127 | 143 | ||
| 128 | 144 | type Loaded = Route.ComponentProps["loaderData"]; | |
| 129 | − | type Pages = NonNullable<Loaded["pages"]>; | |
| 145 | + | type Pages = Exclude<NonNullable<Loaded["pages"]>, Failed>; | |
| 130 | 146 | ||
| 131 | 147 | export default function RepoDeployments({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 132 | − | const { environments, list, pages, filter, can } = loaderData; | |
| 148 | + | const { filter, can } = loaderData; | |
| 149 | + | // A part that could not be read says so in its place; the rest reads it as absent. | |
| 150 | + | const environmentsFailed = isFailed(loaderData.environments); | |
| 151 | + | const listFailed = isFailed(loaderData.list); | |
| 152 | + | const pagesFailed = isFailed(loaderData.pages); | |
| 153 | + | const environments = orElse(loaderData.environments, null); | |
| 154 | + | const list = orElse(loaderData.list, null); | |
| 155 | + | const pages = orElse(loaderData.pages, null); | |
| 133 | 156 | const base = `/${params.owner}/${params.repo}`; | |
| 134 | 157 | const total = environments?.total_count ?? list?.total_count ?? 0; | |
| 135 | 158 | const envs = environments?.environments ?? []; | |
| 136 | 159 | const pagesOn = pages?.settings.enabled ?? false; | |
| 137 | − | // Anything to show besides how to start: deployments, or g1t.page hosting turned on. | |
| 138 | − | const any = total > 0 || envs.length > 0 || pagesOn || (pages?.live.length ?? 0) > 0; | |
| 160 | + | // Anything to show besides how to start: deployments, or g1t.page hosting | |
| 161 | + | // turned on. What could not be read may be there: never say there is | |
| 162 | + | // nothing because of it. | |
| 163 | + | const any = total > 0 || envs.length > 0 || pagesOn || (pages?.live.length ?? 0) > 0 || environmentsFailed || listFailed || pagesFailed; | |
| 139 | 164 | const production = envs.find((env) => env.production_environment && environmentUrl(env)); | |
| 140 | 165 | const productionUrl = production ? environmentUrl(production) : null; | |
| 141 | 166 | const planNeeded = pages?.plan != null && !pages.plan.on && !pages.plan.included && pages.live.length === 0; | |
| ⋯ | |||
| 173 | 198 | {!actionData && pages && <ComputeNote note={pages.computeNote} />} | |
| 174 | 199 | </div> | |
| 175 | 200 | ||
| 176 | − | {!environments && !list ? ( | |
| 201 | + | {environmentsFailed && listFailed ? ( | |
| 202 | + | <Unavailable what="Deployments" /> | |
| 203 | + | ) : !environments && !list && !environmentsFailed && !listFailed ? ( | |
| 177 | 204 | <Card asChild className="p-6 text-sm text-muted"> | |
| 178 | 205 | <p> | |
| 179 | 206 | Deployments could not be loaded just now. Reload the page to try again. | |
| ⋯ | |||
| 183 | 210 | <Start base={base} pages={pages} can={can} owner={params.owner} planNeeded={planNeeded} /> | |
| 184 | 211 | ) : ( | |
| 185 | 212 | <> | |
| 186 | − | {envs.length > 0 && ( | |
| 213 | + | {environmentsFailed ? ( | |
| 187 | 214 | <section aria-labelledby="environments"> | |
| 188 | 215 | <h2 id="environments" className="text-sm font-semibold"> | |
| 189 | − | Environments <span className="font-normal text-faint">{envs.length}</span> | |
| 216 | + | Environments | |
| 190 | 217 | </h2> | |
| 191 | − | <ul className="mt-3 grid gap-3 md:grid-cols-2 lg:grid-cols-3"> | |
| 192 | − | {envs.map((env) => ( | |
| 193 | − | <EnvironmentCard key={env.name} env={env} base={base} /> | |
| 194 | − | ))} | |
| 195 | − | </ul> | |
| 218 | + | <div className="mt-3"> | |
| 219 | + | <Unavailable what="Environments" /> | |
| 220 | + | </div> | |
| 196 | 221 | </section> | |
| 222 | + | ) : ( | |
| 223 | + | envs.length > 0 && ( | |
| 224 | + | <Boundary what="Environments"> | |
| 225 | + | <section aria-labelledby="environments"> | |
| 226 | + | <h2 id="environments" className="text-sm font-semibold"> | |
| 227 | + | Environments <span className="font-normal text-faint">{envs.length}</span> | |
| 228 | + | </h2> | |
| 229 | + | <ul className="mt-3 grid gap-3 md:grid-cols-2 lg:grid-cols-3"> | |
| 230 | + | {envs.map((env) => ( | |
| 231 | + | <EnvironmentCard key={env.name} env={env} base={base} /> | |
| 232 | + | ))} | |
| 233 | + | </ul> | |
| 234 | + | </section> | |
| 235 | + | </Boundary> | |
| 236 | + | ) | |
| 197 | 237 | )} | |
| 198 | 238 | ||
| 199 | − | <History base={base} list={list} filter={filter} environments={envs} /> | |
| 239 | + | {listFailed ? ( | |
| 240 | + | <section id="history" aria-labelledby="history-heading" className="mt-10 scroll-mt-20"> | |
| 241 | + | <h2 id="history-heading" className="text-sm font-semibold"> | |
| 242 | + | History | |
| 243 | + | </h2> | |
| 244 | + | <div className="mt-3"> | |
| 245 | + | <Unavailable what="Deployment history" /> | |
| 246 | + | </div> | |
| 247 | + | </section> | |
| 248 | + | ) : ( | |
| 249 | + | <Boundary what="Deployment history"> | |
| 250 | + | <History base={base} list={list} filter={filter} environments={envs} /> | |
| 251 | + | </Boundary> | |
| 252 | + | )} | |
| 200 | 253 | </> | |
| 201 | 254 | )} | |
| 202 | 255 | ||
| 203 | 256 | {/* g1t.page hosting: its own section once there is anything else to show. */} | |
| 257 | + | {any && pagesFailed && ( | |
| 258 | + | <section aria-labelledby="g1t-page" className="mt-12 border-t border-line pt-8"> | |
| 259 | + | <h2 id="g1t-page" className="flex items-center gap-2 text-sm font-semibold"> | |
| 260 | + | <Globe size={14} className="text-faint" /> | |
| 261 | + | On g1t.page | |
| 262 | + | </h2> | |
| 263 | + | <div className="mt-3"> | |
| 264 | + | <Unavailable what="g1t.page hosting" /> | |
| 265 | + | </div> | |
| 266 | + | </section> | |
| 267 | + | )} | |
| 204 | 268 | {any && pages && ( | |
| 205 | 269 | <section aria-labelledby="g1t-page" className="mt-12 border-t border-line pt-8"> | |
| 206 | 270 | <h2 id="g1t-page" className="flex items-center gap-2 text-sm font-semibold"> | |
| ⋯ | |||
| 391 | 455 | environments, | |
| 392 | 456 | }: { | |
| 393 | 457 | base: string; | |
| 394 | − | list: Loaded["list"]; | |
| 458 | + | list: Exclude<Loaded["list"], Failed>; | |
| 395 | 459 | filter: ListFilter; | |
| 396 | 460 | environments: DeploymentEnvironment[]; | |
| 397 | 461 | }) { | |
| 7 | 7 | import { Card } from "../../components/ui/card"; | |
| 8 | 8 | import { requireRepo } from "../../lib/access.server"; | |
| 9 | 9 | import { page } from "../../lib/meta"; | |
| 10 | + | import { isFailed, part } from "../../lib/partial"; | |
| 11 | + | import { Unavailable } from "../../components/boundary"; | |
| 10 | 12 | import { projects, repos } from "../../lib/services.server"; | |
| 11 | 13 | import { unwrap } from "../../lib/session.server"; | |
| 12 | 14 | ||
| ⋯ | |||
| 21 | 23 | const { viewer, repo } = await requireRepo(context, params, "read"); | |
| 22 | 24 | const found = unwrap(await repos.forks({ namespace: params.owner, name: params.repo }, viewer, LISTED)); | |
| 23 | 25 | // A repository has no description: its project's is shown. | |
| 24 | − | const about = await projects.descriptions(found.map((fork) => fork.id)); | |
| 26 | + | // The forks show without them, and the list says why they have none. | |
| 27 | + | const described = await part(projects.descriptions(found.map((fork) => fork.id)), "Descriptions"); | |
| 28 | + | const about: Record<string, string> = isFailed(described) ? {} : described; | |
| 25 | 29 | return { | |
| 30 | + | descriptionsFailed: isFailed(described) ? described : null, | |
| 26 | 31 | forks: found.map((fork) => ({ | |
| 27 | 32 | namespace: fork.namespace, | |
| 28 | 33 | name: fork.name, | |
| ⋯ | |||
| 37 | 42 | } | |
| 38 | 43 | ||
| 39 | 44 | export default function Forks({ loaderData, params }: Route.ComponentProps) { | |
| 40 | − | const { forks, forkable, signedIn } = loaderData; | |
| 45 | + | const { forks, forkable, signedIn, descriptionsFailed } = loaderData; | |
| 41 | 46 | const base = `/${params.owner}/${params.repo}`; | |
| 42 | 47 | return ( | |
| 43 | 48 | <div className="space-y-4"> | |
| ⋯ | |||
| 56 | 61 | </ButtonLink> | |
| 57 | 62 | )} | |
| 58 | 63 | </div> | |
| 64 | + | {descriptionsFailed && forks.length > 0 && <Unavailable compact what={descriptionsFailed.what} />} | |
| 59 | 65 | {forks.length === 0 ? ( | |
| 60 | 66 | <EmptyState title="No forks yet"> | |
| 61 | 67 | {forkable | |
| 13 | 13 | import { integrations, work } from "../../lib/services.server"; | |
| 14 | 14 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| 15 | 15 | import { accessTo } from "../../lib/access.server"; | |
| 16 | + | import { Unavailable } from "../../components/boundary"; | |
| 17 | + | import { isFailed, part } from "../../lib/partial"; | |
| 16 | 18 | ||
| 17 | 19 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 18 | 20 | return page(args, { title: `New issue · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 22 | 24 | const user = requireUser(context, request); | |
| 23 | 25 | const path = { namespace: params.owner, name: params.repo }; | |
| 24 | 26 | const { can } = await accessTo(context, params); | |
| 27 | + | // The form is what this page is about; what fills its choices is a part | |
| 28 | + | // each, which says so in its own place when its service fails. | |
| 25 | 29 | const [labels, milestones, connections] = await Promise.all([ | |
| 26 | − | work.listLabels(path, user), | |
| 27 | − | can.triage ? work.listMilestones(path, user, "open") : null, | |
| 28 | − | integrations.list(params.owner.toLowerCase(), user), | |
| 30 | + | part(work.listLabels(path, user), "Labels"), | |
| 31 | + | can.triage ? part(work.listMilestones(path, user, "open"), "Milestones") : null, | |
| 32 | + | // Only a hint at what can be imported: the page does without it. | |
| 33 | + | integrations.list(params.owner.toLowerCase(), user).catch(() => null), | |
| 29 | 34 | ]); | |
| 30 | 35 | // Systems a ticket can be imported from. | |
| 31 | − | const sources = connections.ok | |
| 36 | + | const sources = connections?.ok | |
| 32 | 37 | ? [...new Set(connections.value.filter((c) => c.kind === "tracker" || c.provider === "sentry").map((c) => c.provider))] | |
| 33 | 38 | : []; | |
| 34 | 39 | return { | |
| 35 | − | labels: unwrap(labels), | |
| 36 | − | milestones: milestones?.ok ? milestones.value : [], | |
| 40 | + | labels: isFailed(labels) ? labels : unwrap(labels), | |
| 41 | + | milestones: isFailed(milestones) ? milestones : milestones?.ok ? milestones.value : [], | |
| 37 | 42 | // Making labels, and choosing a milestone, need Triage. | |
| 38 | 43 | canTriage: can.triage, | |
| 39 | 44 | // Making labels needs Write; choosing them and a milestone, Triage. | |
| ⋯ | |||
| 123 | 128 | </Field> | |
| 124 | 129 | <fieldset> | |
| 125 | 130 | <legend className="mb-1.5 text-sm font-medium text-muted">Labels</legend> | |
| 126 | − | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 127 | − | {loaderData.labels.map((label) => ( | |
| 128 | − | <CheckboxOption | |
| 129 | − | key={label.name} | |
| 130 | − | name="label" | |
| 131 | − | value={label.name} | |
| 132 | − | label={<LabelChip name={label.name} color={label.color} />} | |
| 133 | − | className="items-center gap-1.5" | |
| 134 | − | /> | |
| 135 | − | ))} | |
| 136 | − | </div> | |
| 131 | + | {isFailed(loaderData.labels) ? ( | |
| 132 | + | <Unavailable what="The repository's labels" compact /> | |
| 133 | + | ) : ( | |
| 134 | + | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 135 | + | {loaderData.labels.map((label) => ( | |
| 136 | + | <CheckboxOption | |
| 137 | + | key={label.name} | |
| 138 | + | name="label" | |
| 139 | + | value={label.name} | |
| 140 | + | label={<LabelChip name={label.name} color={label.color} />} | |
| 141 | + | className="items-center gap-1.5" | |
| 142 | + | /> | |
| 143 | + | ))} | |
| 144 | + | </div> | |
| 145 | + | )} | |
| 137 | 146 | {loaderData.canMakeLabels && ( | |
| 138 | 147 | <div className="mt-2"> | |
| 139 | 148 | <Input | |
| ⋯ | |||
| 144 | 153 | </div> | |
| 145 | 154 | )} | |
| 146 | 155 | </fieldset> | |
| 147 | − | {loaderData.canTriage && loaderData.milestones.length > 0 && ( | |
| 156 | + | {loaderData.canTriage && isFailed(loaderData.milestones) && ( | |
| 157 | + | <Field label="Milestone"> | |
| 158 | + | <Unavailable what="Milestones" compact /> | |
| 159 | + | </Field> | |
| 160 | + | )} | |
| 161 | + | {loaderData.canTriage && !isFailed(loaderData.milestones) && loaderData.milestones.length > 0 && ( | |
| 148 | 162 | <Field label="Milestone"> | |
| 149 | 163 | <SelectField | |
| 150 | 164 | name="milestone" | |
| 41 | 41 | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 42 | 42 | import { DecisionList, IntegrationFailure, IssueWorkPanel, StartSessionDialog, TimelineSession } from "../../components/coordination"; | |
| 43 | 43 | import { Reactions } from "../../components/reactions"; | |
| 44 | + | import { Boundary, Unavailable } from "../../components/boundary"; | |
| 45 | + | import { isFailed, part } from "../../lib/partial"; | |
| 44 | 46 | import { parseBlockedBy, resolutionOf } from "../../lib/coordination"; | |
| 45 | 47 | ||
| 46 | 48 | ||
| ⋯ | |||
| 65 | 67 | const path = { namespace: params.owner, name: params.repo }; | |
| 66 | 68 | const number = Number(params.number); | |
| 67 | 69 | const access = accessTo(context, params); | |
| 70 | + | // The issue and who may do what stay strict: without them there is no | |
| 71 | + | // page. Everything beside it is a part (lib/partial.ts), which says it | |
| 72 | + | // could not be loaded in its own place when its service fails. | |
| 68 | 73 | const [{ can }, found, labels, milestones, members, links, subscription, teams, coordination, ended, sessionTrees, agentSetup] = await Promise.all([ | |
| 69 | 74 | access, | |
| 70 | 75 | work.getIssue(path, number, viewer), | |
| 71 | − | work.listLabels(path, viewer), | |
| 76 | + | part(work.listLabels(path, viewer), "Labels"), | |
| 72 | 77 | // Open ones to choose from, and the one it is in, with its progress. | |
| 73 | − | work.listMilestones(path, viewer), | |
| 78 | + | part(work.listMilestones(path, viewer), "Milestones"), | |
| 74 | 79 | // A member picks assignees from the workspace's people. | |
| 75 | − | roleIn(viewer, params.owner) ? identity.listMembers(params.owner, viewer) : null, | |
| 80 | + | roleIn(viewer, params.owner) ? part(identity.listMembers(params.owner, viewer), "The workspace's people") : null, | |
| 76 | 81 | // What it is tied to outside g1t. Shown only once the issue is known visible. | |
| 77 | − | integrations.links(path, number).catch(() => []), | |
| 82 | + | part(integrations.links(path, number), "Links from outside g1t"), | |
| 78 | 83 | // Whether the viewer hears of it, for the sidebar's Notifications. | |
| 79 | 84 | viewer | |
| 80 | 85 | ? repoFor(context, params).then((repo) => | |
| 81 | − | repo.ok ? inbox.subscription(viewer, { repoId: repo.value.id, number }).catch(() => null) : null, | |
| 86 | + | repo.ok ? part(inbox.subscription(viewer, { repoId: repo.value.id, number }), "Notifications") : null, | |
| 82 | 87 | ) | |
| 83 | 88 | : null, | |
| 84 | − | // The teams a member can mention, for the comment box. | |
| 89 | + | // The teams a member can mention, for the comment box: only a | |
| 90 | + | // suggestion, so the box quietly does without them. | |
| 85 | 91 | roleIn(viewer, params.owner) ? identity.listTeams(viewer, params.owner).catch(() => null) : null, | |
| 86 | 92 | // Who is on it, its sessions, what it waits on and its decisions | |
| 87 | 93 | // (services/work/src/coordination): the Work panel. | |
| 88 | − | work.getWork(path, number, viewer).catch(() => null), | |
| 89 | − | // Its claims that ended, shown muted under the active ones. | |
| 94 | + | part(work.getWork(path, number, viewer), "Work"), | |
| 95 | + | // Its claims that ended, shown muted under the active ones: history | |
| 96 | + | // the panel can do without when it cannot be read. | |
| 90 | 97 | Promise.all( | |
| 91 | 98 | (["released", "expired", "handed_off"] as const).map((state) => | |
| 92 | 99 | work.listClaims(path, viewer, { state, issue: number }).catch(() => null), | |
| 93 | 100 | ), | |
| 94 | 101 | ), | |
| 95 | 102 | // Every session tied to it in any way, with their subagents, for the Work panel's trees. | |
| 96 | − | work.listWorkSessions(path, viewer, { issue: number, subagents: true }).catch(() => null), | |
| 97 | − | // Whether a member can start a session on the workspace's Anthropic account. | |
| 103 | + | part(work.listWorkSessions(path, viewer, { issue: number, subagents: true }), "Sessions"), | |
| 104 | + | // Whether a member can start a session on the workspace's Anthropic | |
| 105 | + | // account: the button is simply not offered when this cannot be read. | |
| 98 | 106 | roleIn(viewer, params.owner) ? agentSessions.setup(params.owner, viewer).catch(() => null) : null, | |
| 99 | 107 | ]); | |
| 100 | 108 | if (!found.ok) { | |
| ⋯ | |||
| 107 | 115 | return { | |
| 108 | 116 | ...found.value, | |
| 109 | 117 | viewer, | |
| 110 | − | labels: labels.ok ? labels.value : [], | |
| 111 | − | milestones: milestones.ok ? milestones.value : [], | |
| 118 | + | labels: isFailed(labels) ? labels : labels.ok ? labels.value : [], | |
| 119 | + | milestones: isFailed(milestones) ? milestones : milestones.ok ? milestones.value : [], | |
| 112 | 120 | links, | |
| 113 | 121 | subscription, | |
| 114 | − | coordination: coordination?.ok ? coordination.value : null, | |
| 115 | − | sessionTrees: sessionTrees?.ok ? sessionTrees.value : null, | |
| 122 | + | coordination: isFailed(coordination) ? coordination : coordination.ok ? coordination.value : null, | |
| 123 | + | sessionTrees: isFailed(sessionTrees) ? sessionTrees : sessionTrees.ok ? sessionTrees.value : null, | |
| 116 | 124 | // Only when it can start: the button is not shown otherwise. | |
| 117 | 125 | agentSetup: agentSetup?.ok && agentSetup.value.available ? agentSetup.value : null, | |
| 118 | 126 | endedClaims: ended | |
| 119 | 127 | .flatMap((claims) => (claims?.ok ? claims.value : [])) | |
| 120 | 128 | .sort((a, b) => Date.parse(b.endedAt ?? b.createdAt) - Date.parse(a.endedAt ?? a.createdAt)), | |
| 121 | − | members: members?.ok ? members.value.map((member) => member.username) : [], | |
| 129 | + | members: isFailed(members) ? members : members?.ok ? members.value.map((member) => member.username) : [], | |
| 122 | 130 | handles: teams?.ok ? teams.value.map((team) => `@${team.workspace}/${team.slug}`) : [], | |
| 123 | 131 | // The author can close and reopen their own issue, and whoever g1t's | |
| 124 | 132 | // agent filed one for, that one; Triage and up, anyone's. | |
| ⋯ | |||
| 313 | 321 | } | |
| 314 | 322 | ||
| 315 | 323 | export default function IssuePage({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 316 | − | const { issue, pulls, comments, viewer, labels, members, canManage, can, coordination } = loaderData; | |
| 324 | + | const { issue, pulls, comments, viewer, labels, members, canManage, can } = loaderData; | |
| 325 | + | // Its Work, when it could be read; a failure says so in the Work panel's place. | |
| 326 | + | const coordination = isFailed(loaderData.coordination) ? null : loaderData.coordination; | |
| 327 | + | const sessionTrees = isFailed(loaderData.sessionTrees) ? null : loaderData.sessionTrees; | |
| 317 | 328 | // Follow pull requests at work without a manual reload. | |
| 318 | 329 | const running = pulls.some( | |
| 319 | 330 | (pull) => | |
| ⋯ | |||
| 370 | 381 | </span> | |
| 371 | 382 | </span> | |
| 372 | 383 | {issue.labels.map((name) => ( | |
| 373 | − | <LabelChip key={name} name={name} color={labels.find((label) => label.name === name)?.color} /> | |
| 384 | + | // Without the repository's labels, a chip still names its label, in the plain color. | |
| 385 | + | <LabelChip key={name} name={name} color={isFailed(labels) ? undefined : labels.find((label) => label.name === name)?.color} /> | |
| 374 | 386 | ))} | |
| 375 | 387 | {open && issue.agent && <Assignee agent={issue.agent} />} | |
| 376 | 388 | </div> | |
| ⋯ | |||
| 462 | 474 | <IntegrationFailure className="mt-4" failure={failure} base={base} fixed={resolution?.passed ?? resolution?.fix} /> | |
| 463 | 475 | )} | |
| 464 | 476 | ||
| 465 | − | {decisions.length > 0 && ( | |
| 477 | + | {isFailed(loaderData.coordination) ? ( | |
| 478 | + | <> | |
| 479 | + | <h3 className="mt-10 font-semibold tracking-tight">Decisions</h3> | |
| 480 | + | <div className="mt-3"> | |
| 481 | + | <Unavailable what="Its decisions" /> | |
| 482 | + | </div> | |
| 483 | + | </> | |
| 484 | + | ) : decisions.length > 0 && ( | |
| 466 | 485 | <> | |
| 467 | 486 | <div className="mt-10 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> | |
| 468 | 487 | <h3 className="font-semibold tracking-tight">Decisions</h3> | |
| ⋯ | |||
| 477 | 496 | <h3 className="mt-10 font-semibold tracking-tight">Discussion</h3> | |
| 478 | 497 | <div className="mt-3"> | |
| 479 | 498 | <div className="space-y-4"> | |
| 499 | + | {isFailed(loaderData.sessionTrees) && <Unavailable what="Its sessions" compact />} | |
| 480 | 500 | <CommentList | |
| 481 | 501 | comments={comments} | |
| 482 | 502 | base={base} | |
| 483 | 503 | viewerId={viewer?.id ?? null} | |
| 484 | 504 | viewerName={viewer?.username ?? null} | |
| 485 | − | extra={(loaderData.sessionTrees ?? []) | |
| 505 | + | extra={(sessionTrees ?? []) | |
| 486 | 506 | .filter((session) => !session.parentId) | |
| 487 | 507 | .map((session) => ({ id: session.id, at: session.startedAt, node: <TimelineSession session={session} base={base} /> }))} | |
| 488 | 508 | canModerate={loaderData.canModerate} | |
| ⋯ | |||
| 526 | 546 | [issue.milestone ? 1 : 0, "milestone", "milestones"], | |
| 527 | 547 | ])} | |
| 528 | 548 | > | |
| 529 | − | {coordination && ( | |
| 530 | − | <IssueWorkPanel | |
| 531 | − | work={coordination} | |
| 532 | − | ended={loaderData.endedClaims} | |
| 533 | − | base={base} | |
| 534 | − | now={now} | |
| 535 | − | canEdit={canManage} | |
| 536 | − | error={actionData && "action" in actionData && actionData.action === "blocked-by" ? actionData.error : null} | |
| 537 | − | sessions={loaderData.sessionTrees} | |
| 538 | − | start={ | |
| 539 | − | loaderData.agentSetup && open ? ( | |
| 540 | − | <StartSessionDialog | |
| 541 | − | setup={loaderData.agentSetup} | |
| 542 | − | error={actionData && "action" in actionData && actionData.action === "start-session" ? actionData.error : null} | |
| 543 | − | /> | |
| 544 | − | ) : undefined | |
| 545 | − | } | |
| 546 | − | /> | |
| 549 | + | {isFailed(loaderData.coordination) ? ( | |
| 550 | + | <section> | |
| 551 | + | <h3 className="text-sm font-medium">Work</h3> | |
| 552 | + | <div className="mt-2"> | |
| 553 | + | <Unavailable what="Who is on it" compact /> | |
| 554 | + | </div> | |
| 555 | + | </section> | |
| 556 | + | ) : coordination && ( | |
| 557 | + | <Boundary what="Work" fallback={<Unavailable what="Who is on it" compact />}> | |
| 558 | + | <IssueWorkPanel | |
| 559 | + | work={coordination} | |
| 560 | + | ended={loaderData.endedClaims} | |
| 561 | + | base={base} | |
| 562 | + | now={now} | |
| 563 | + | canEdit={canManage} | |
| 564 | + | error={actionData && "action" in actionData && actionData.action === "blocked-by" ? actionData.error : null} | |
| 565 | + | sessions={sessionTrees} | |
| 566 | + | start={ | |
| 567 | + | loaderData.agentSetup && open ? ( | |
| 568 | + | <StartSessionDialog | |
| 569 | + | setup={loaderData.agentSetup} | |
| 570 | + | error={actionData && "action" in actionData && actionData.action === "start-session" ? actionData.error : null} | |
| 571 | + | /> | |
| 572 | + | ) : undefined | |
| 573 | + | } | |
| 574 | + | /> | |
| 575 | + | </Boundary> | |
| 547 | 576 | )} | |
| 548 | − | {loaderData.links.length > 0 && ( | |
| 577 | + | {isFailed(loaderData.links) ? ( | |
| 549 | 578 | <section> | |
| 550 | 579 | <h3 className="text-sm font-medium">From outside g1t</h3> | |
| 580 | + | <div className="mt-2"> | |
| 581 | + | <Unavailable what="Its links" compact /> | |
| 582 | + | </div> | |
| 583 | + | </section> | |
| 584 | + | ) : loaderData.links.length > 0 && ( | |
| 585 | + | <section> | |
| 586 | + | <h3 className="text-sm font-medium">From outside g1t</h3> | |
| 551 | 587 | <ul className="mt-2 space-y-1.5 text-sm"> | |
| 552 | 588 | {loaderData.links.map((link) => ( | |
| 553 | 589 | <li key={`${link.connectionId}-${link.key}`}> | |
| ⋯ | |||
| 641 | 677 | </summary> | |
| 642 | 678 | <Form method="post" className="mt-2 space-y-2" key={issue.assignees.join()}> | |
| 643 | 679 | <input type="hidden" name="action" value="assign" /> | |
| 644 | − | <PeoplePicker name="assignee" members={members} chosen={issue.assignees} /> | |
| 680 | + | {isFailed(members) ? ( | |
| 681 | + | <Unavailable what="The workspace's people" compact /> | |
| 682 | + | ) : ( | |
| 683 | + | <PeoplePicker name="assignee" members={members} chosen={issue.assignees} /> | |
| 684 | + | )} | |
| 645 | 685 | <SubmitButton variant="outline" name="who" value="picked" pending="Saving…"> | |
| 646 | 686 | Save assignees | |
| 647 | 687 | </SubmitButton> | |
| ⋯ | |||
| 697 | 737 | </Card> | |
| 698 | 738 | )} | |
| 699 | 739 | ||
| 700 | − | <LabelsBox | |
| 701 | − | labels={labels} | |
| 702 | − | chosen={issue.labels} | |
| 703 | − | canEdit={loaderData.canManage} | |
| 704 | − | canCreate={can.manage_labels} | |
| 705 | − | manageUrl={can.manage_labels ? `${base}/labels` : undefined} | |
| 706 | − | /> | |
| 707 | − | <MilestoneBox | |
| 708 | − | milestones={loaderData.milestones} | |
| 709 | − | current={issue.milestone} | |
| 710 | − | canEdit={can.triage} | |
| 711 | − | base={base} | |
| 712 | − | /> | |
| 713 | − | ||
| 714 | − | {viewer && ( | |
| 715 | − | <SubscriptionBox action={`${base}/notifications`} number={issue.number} kind="issue" subscription={loaderData.subscription} /> | |
| 740 | + | {isFailed(labels) ? ( | |
| 741 | + | <SideUnavailable heading="Labels" what="Labels" /> | |
| 742 | + | ) : ( | |
| 743 | + | <LabelsBox | |
| 744 | + | labels={labels} | |
| 745 | + | chosen={issue.labels} | |
| 746 | + | canEdit={loaderData.canManage} | |
| 747 | + | canCreate={can.manage_labels} | |
| 748 | + | manageUrl={can.manage_labels ? `${base}/labels` : undefined} | |
| 749 | + | /> | |
| 750 | + | )} | |
| 751 | + | {isFailed(loaderData.milestones) ? ( | |
| 752 | + | <SideUnavailable heading="Milestone" what="Milestones" /> | |
| 753 | + | ) : ( | |
| 754 | + | <MilestoneBox | |
| 755 | + | milestones={loaderData.milestones} | |
| 756 | + | current={issue.milestone} | |
| 757 | + | canEdit={can.triage} | |
| 758 | + | base={base} | |
| 759 | + | /> | |
| 716 | 760 | )} | |
| 761 | + | ||
| 762 | + | {viewer && | |
| 763 | + | (isFailed(loaderData.subscription) ? ( | |
| 764 | + | <SideUnavailable heading="Notifications" what="Your notification settings" /> | |
| 765 | + | ) : ( | |
| 766 | + | <SubscriptionBox action={`${base}/notifications`} number={issue.number} kind="issue" subscription={loaderData.subscription} /> | |
| 767 | + | ))} | |
| 717 | 768 | </DetailsDisclosure> | |
| 718 | 769 | </div> | |
| 719 | 770 | ); | |
| 720 | 771 | } | |
| 772 | + | ||
| 773 | + | /** A sidebar section, under its usual heading, that could not be loaded. */ | |
| 774 | + | function SideUnavailable({ heading, what }: { heading: string; what: string }) { | |
| 775 | + | return ( | |
| 776 | + | <section> | |
| 777 | + | <h3 className="text-sm font-medium">{heading}</h3> | |
| 778 | + | <div className="mt-2"> | |
| 779 | + | <Unavailable what={what} compact /> | |
| 780 | + | </div> | |
| 781 | + | </section> | |
| 782 | + | ); | |
| 783 | + | } | |
| 18 | 18 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 19 | 19 | import { repoPages } from "../../lib/repo-live"; | |
| 20 | 20 | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 21 | + | import { isFailed, part } from "../../lib/partial"; | |
| 21 | 22 | ||
| 22 | 23 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 23 | 24 | return page(args, { title: `Issues · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 29 | 30 | // `?label=bug&milestone=3`, or `label:bug milestone:3` written in `q`. | |
| 30 | 31 | const { state, label, milestone } = listFilters(new URL(request.url).searchParams); | |
| 31 | 32 | const [issues, labels, milestones] = await Promise.all([ work.listIssues(path, viewer, { state, label: label || undefined, milestone: milestone ?? undefined }), | |
| 32 | − | work.listLabels(path, viewer), | |
| 33 | − | work.listMilestones(path, viewer, "open"), | |
| 33 | + | // What the filters offer: each a part, whose menu says so when its service fails. | |
| 34 | + | part(work.listLabels(path, viewer), "Labels"), | |
| 35 | + | part(work.listMilestones(path, viewer, "open"), "Milestones"), | |
| 34 | 36 | ]); | |
| 35 | 37 | return { | |
| 36 | 38 | issues: unwrap(issues), | |
| 37 | − | labels: unwrap(labels), | |
| 38 | − | milestones: milestones.ok ? milestones.value : [], | |
| 39 | + | labels: isFailed(labels) ? labels : unwrap(labels), | |
| 40 | + | milestones: isFailed(milestones) ? milestones : milestones.ok ? milestones.value : [], | |
| 39 | 41 | state, | |
| 40 | 42 | label, | |
| 41 | 43 | milestone, | |
| ⋯ | |||
| 43 | 45 | } | |
| 44 | 46 | ||
| 45 | 47 | export default function Issues({ loaderData, params }: Route.ComponentProps) { | |
| 46 | − | const { issues, labels, milestones, state, label, milestone } = loaderData; | |
| 48 | + | const { issues, state, label, milestone } = loaderData; | |
| 49 | + | // A filter whose choices could not be loaded offers none, and its menu says why. | |
| 50 | + | const labels = isFailed(loaderData.labels) ? [] : loaderData.labels; | |
| 51 | + | const milestones = isFailed(loaderData.milestones) ? [] : loaderData.milestones; | |
| 47 | 52 | // Issues opened, commented on and closed show as it happens. | |
| 48 | 53 | useRepoLive({ match: repoPages.issues() }); | |
| 49 | 54 | const repo = `/${params.owner}/${params.repo}`; | |
| ⋯ | |||
| 81 | 86 | active={label || undefined} | |
| 82 | 87 | clearTo={label ? withFilter(base, current, "label", null) : undefined} | |
| 83 | 88 | searchPlaceholder="Filter labels" | |
| 84 | − | emptyText="No label matches." | |
| 89 | + | emptyText={isFailed(loaderData.labels) ? "Labels could not be loaded just now." : "No label matches."} | |
| 85 | 90 | options={labels.map((each) => ({ | |
| 86 | 91 | key: each.name, | |
| 87 | 92 | to: withFilter(base, current, "label", each.name), | |
| ⋯ | |||
| 100 | 105 | active={milestoneTitle ?? undefined} | |
| 101 | 106 | clearTo={milestone ? withFilter(base, current, "milestone", null) : undefined} | |
| 102 | 107 | searchPlaceholder="Filter milestones" | |
| 103 | − | emptyText="No open milestone matches." | |
| 108 | + | emptyText={isFailed(loaderData.milestones) ? "Milestones could not be loaded just now." : "No open milestone matches."} | |
| 104 | 109 | options={milestones.map((each) => ({ | |
| 105 | 110 | key: String(each.number), | |
| 106 | 111 | to: withFilter(base, current, "milestone", String(each.number)), | |
| 21 | 21 | import { IssueIcon, PullIcon } from "../../components/work"; | |
| 22 | 22 | import { requireRepo } from "../../lib/access.server"; | |
| 23 | 23 | import { colorsOf } from "../../lib/labels"; | |
| 24 | + | import { orElse, part } from "../../lib/partial"; | |
| 24 | 25 | import { page } from "../../lib/meta"; | |
| 25 | 26 | import { milestoneAction } from "../../lib/milestones.server"; | |
| 26 | 27 | import { openedBy } from "../../lib/opened-by"; | |
| ⋯ | |||
| 37 | 38 | const state = new URL(request.url).searchParams.get("state") === "closed" ? "closed" : "open"; | |
| 38 | 39 | const [found, labels] = await Promise.all([ | |
| 39 | 40 | work.getMilestone(path, Number(params.number), viewer), | |
| 40 | − | work.listLabels(path, viewer), | |
| 41 | + | // Only its chips' colors: without them a chip still names its label. | |
| 42 | + | part(work.listLabels(path, viewer), "Labels").then((labels) => orElse(labels, null)), | |
| 41 | 43 | ]); | |
| 42 | 44 | if (!found.ok) throw new Response("Milestone not found.", { status: 404 }); | |
| 43 | 45 | return { | |
| 44 | 46 | detail: found.value, | |
| 45 | − | labels: labels.ok ? labels.value : [], | |
| 47 | + | labels: labels?.ok ? labels.value : [], | |
| 46 | 48 | state, | |
| 47 | 49 | canEdit: access.can.manage_labels, | |
| 48 | 50 | today: new Date().toISOString(), | |
| 312 | 312 | claims: ok(claims) ?? [], | |
| 313 | 313 | sessions: [...(ok(active) ?? []), ...(ok(idle) ?? [])], | |
| 314 | 314 | liveLoaded: claims?.ok === true || active?.ok === true, | |
| 315 | + | // Sections that could not be read say so in their place, rather than | |
| 316 | + | // reading as empty: what needs someone signed in (their open pull | |
| 317 | + | // requests), the deployments panel, and a member's observability. | |
| 318 | + | needsLoaded: viewer == null || open != null, | |
| 319 | + | environmentsLoaded: environments != null, | |
| 320 | + | telemetryLoaded: observed == null || observed.overview != null, | |
| 315 | 321 | titles, | |
| 316 | 322 | livePulls, | |
| 317 | 323 | defaultBranch, | |
| ⋯ | |||
| 887 | 893 | ||
| 888 | 894 | <div className="grid gap-x-8 gap-y-8 lg:grid-cols-[minmax(0,1fr)_20rem]"> | |
| 889 | 895 | <div className="min-w-0 space-y-8"> | |
| 890 | − | {needs.length > 0 && ( | |
| 896 | + | {!loaderData.needsLoaded && ( | |
| 897 | + | <Panel title="Needs you" icon={<Hand size={14} className="text-warn" />}> | |
| 898 | + | <Unavailable what="What needs you" /> | |
| 899 | + | </Panel> | |
| 900 | + | )} | |
| 901 | + | {loaderData.needsLoaded && needs.length > 0 && ( | |
| 891 | 902 | <Panel title="Needs you" icon={<Hand size={14} className="text-warn" />} count={needs.length}> | |
| 892 | 903 | <NeedsList needs={needs} limit={5} /> | |
| 893 | 904 | </Panel> | |
| ⋯ | |||
| 970 | 981 | ||
| 971 | 982 | <aside className="space-y-4 text-sm"> | |
| 972 | 983 | {/* Deployments panel (deployments-panel.tsx): each environment's latest deployment. */} | |
| 973 | − | <DeploymentsPanel base={base} summary={loaderData.environments} className="rounded-xl border border-line bg-surface p-5" /> | |
| 984 | + | {loaderData.environmentsLoaded ? ( | |
| 985 | + | <DeploymentsPanel base={base} summary={loaderData.environments} className="rounded-xl border border-line bg-surface p-5" /> | |
| 986 | + | ) : ( | |
| 987 | + | <Unavailable what="Deployments" compact /> | |
| 988 | + | )} | |
| 989 | + | {!loaderData.telemetryLoaded && <Unavailable what="Observability" compact />} | |
| 974 | 990 | {loaderData.telemetry && <ObservabilityCard overview={loaderData.telemetry.overview} errors={loaderData.telemetry.errors} base={base} />} | |
| 975 | 991 | {project && <AboutCard project={project} canChange={canChange} base={base} release={loaderData.release} />} | |
| 976 | 992 | <HealthCard loaderData={loaderData} base={base} plan={plan} /> | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Comments
Sign in to comment.