Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

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
syntaqxcommitted Parent704e654Browse files
31 files+1082−4420/31 viewed
+55−16
2727 import type { PackageSummary, Repo, RepoAbout } from "@g1t/contracts";
2828
2929 import { compact, count, languageBar, licenseLabel } from "../lib/about";
30+import { type Failed, isFailed } from "../lib/partial";
31+import { Unavailable } from "./boundary";
3032 import { contributorPerson, profileHref, shownName } from "../lib/commit-people";
3133 import { CommitAvatar, CommitName } from "./commit-person";
3234 import { UserCard } from "./user-card";
194196 );
195197 }
196198
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;
198208 return (
199209 <Section title="Packages" count={packages?.length}>
200210 {packages && packages.length > 0 ? (
356366 base: string;
357367 first: RepoAbout;
358368 canPush: boolean;
359− packages: Promise<PackageSummary[] | null> | PackageSummary[] | null;
369+ packages: Promise<PackageSummary[] | Failed | null> | PackageSummary[] | Failed | null;
360370 tags: Promise<number | null> | number | null;
361371 deployments: ReactNode;
362372 }) {
369379 </Await>
370380 </Suspense>
371381 <Suspense fallback={<SectionSkeleton title="Packages" />}>
372− <Await resolve={packages} errorElement={<Packages packages={null} />}>
382+ <Await resolve={packages} errorElement={<Packages packages={{ failed: true, what: "Packages" }} />}>
373383 {(found) => <Packages packages={found} />}
374384 </Await>
375385 </Suspense>
382392 );
383393 }
384394
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+
385404 function SectionSkeleton({ title }: { title: string }) {
386405 return (
387406 <section className="border-t border-line pt-4" aria-busy="true">
392411 }
393412
394413 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;
397416 /** How many watch it; null when it could not be read. */
398417 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;
401420 /** How many tags it has; null when they could not be counted. */
402421 tags?: Promise<number | null> | number | null;
403422 };
433452 )}
434453 <Suspense fallback={<FactsSkeleton />}>
435454 <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+ }}
443467 </Await>
444468 </Suspense>
445469 </ul>
458482 </>
459483 }
460484 >
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+ >
462494 {(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 ? (
464503 <LoadedSections base={base} first={about} canPush={canPush} packages={data.packages} tags={data.tags ?? null} deployments={deployments} />
465504 ) : (
466505 <>{deployments}</>
+70−42
1515 import { CodeLines } from "./code-lines";
1616
1717 import { useAddresses } from "../lib/addresses";
18+import { type Failed, isFailed } from "../lib/partial";
19+import { Boundary, Unavailable } from "./boundary";
1820 import { isImagePath } from "../lib/usercontent";
1921 import { CloneBox } from "./clone-box";
2022 import { type ChecksSource, CommitChecksBadge } from "./commit-checks";
131133 gitRef,
132134 path,
133135 branches,
136+ failed = null,
134137 view = "tree",
135138 }: {
136139 base: string;
137140 gitRef: string;
138141 path: string;
139142 branches: Branch[];
143+ /** The branch list could not be loaded: the menu says so under the one shown. */
144+ failed?: Failed | null;
140145 /** Whether `path` is a folder or a file. */
141146 view?: "tree" | "blob";
142147 }) {
158163 </Link>
159164 </DropdownMenuItem>
160165 ))}
166+ {failed && (
167+ <div className="px-2 py-1.5">
168+ <Unavailable compact what={failed.what} />
169+ </div>
170+ )}
161171 </DropdownMenuContent>
162172 </DropdownMenu>
163173 );
226236 repo: { namespace: string; name: string };
227237 gitRef: string;
228238 path: string;
229− branches: Branch[] | null;
239+ branches: Branch[] | Failed | null;
230240 }) {
231241 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: "" }];
233245 return (
234246 <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} />
236248 {path ? (
237249 <Breadcrumbs base={base} repo={repo.name} gitRef={gitRef} path={path} />
238250 ) : (
239251 <>
240− {branches && (
252+ {known && (
241253 <Link to={`${base}/branches`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent">
242254 <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"}
245257 </Link>
246258 )}
247259 <Link to={`${base}/tags`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent">
341353 tree: Tree;
342354 /** Above the files of a fork: where it came from, Sync fork and Contribute (components/fork-bar.tsx). */
343355 fork?: ReactNode;
344− branches?: Branch[] | null;
356+ /** For the branch menu; Failed when it could not be loaded. */
357+ branches?: Branch[] | Failed | null;
345358 /** Each entry's last commit, streamed in after the list. */
346359 lastCommits?: Promise<FileCommits | null> | null;
347360 /** The latest commit's checks, streamed in. */
350363 about?: AboutData | null;
351364 /** Whether the viewer may push, for the About's "Create a new release". */
352365 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;
355368 }) {
356369 const { repo, ref, path, head, entries, readme } = tree;
357370 const base = `/${repo.namespace}/${repo.name}`;
394407 </Card>
395408
396409 {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>
419435 )}
420436 </div>
421437
422438 {!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>
431458 )}
432459 </div>
433460 );
441468 notice,
442469 marked,
443470 }: {
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;
446473 /** Said above the file, such as what is wrong with it. */
447474 notice?: ReactNode;
448475 /** Lines to mark as having a problem, 1-based. */
478505 base={base}
479506 gitRef={ref}
480507 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}
482510 view="blob"
483511 />
484512 <Breadcrumbs base={base} repo={repo.name} gitRef={ref} path={path} />
+14−4
22 * The Files page's About, streamed in after the files: what the default
33 * branch says, stars and releases from repos; how many watch it from the
44 * 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.
79 */
810 import type { PackageSummary, RepoAbout, RepoPath, Viewer } from "@g1t/contracts";
911
1012 import type { AboutData } from "../components/repo-about";
13+import { type Failed, part } from "./partial";
1114 import { inbox, packages, repos } from "./services.server";
1215
1316 /** How long a section waits before it shows without its answer. */
1720 return Promise.race([promise.catch(() => null), new Promise<null>((resolve) => setTimeout(() => resolve(null), WAIT_MS))]);
1821 }
1922
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+
2029 export function aboutOf(path: RepoPath, viewer: Viewer): Promise<RepoAbout | null> {
2130 return repos.about(path, viewer).then((found) => (found.ok ? found.value : null));
2231 }
2332
2433 export function aboutFor(path: RepoPath, viewer: Viewer, repoId: string): AboutData {
2534 return {
26− about: within(aboutOf(path, viewer)),
35+ about: partWithin(aboutOf(path, viewer), "Releases, contributors and languages"),
2736 watchers: within(inbox.watchers(repoId)),
28− packages: within(
37+ packages: partWithin(
2938 packages.list(path.namespace, viewer, { repoId }).then((found): PackageSummary[] | null => (found.ok ? found.value : null)),
39+ "Packages",
3040 ),
3141 tags: within(repos.tags(path, viewer).then((found) => (found.ok ? found.value.length : null))),
3242 };
+5−9
44
55 /**
66 * 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.
910 */
1011 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;
1814 }
+12−15
77 /**
88 * The repository the one at `params` was forked from, for its fork bar
99 * (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.
1112 */
1213 export async function forkSourceFor(
1314 context: Parameters<typeof repoFor>[0],
1415 params: { owner?: string; repo?: string },
1516 viewer: Viewer,
1617 ): 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+ };
3128 }
+55−19
3636
3737 import type { Route } from "./+types/actions-run";
3838 import { page } from "../../lib/meta";
39+import { isFailed, part } from "../../lib/partial";
40+import { Boundary, Unavailable } from "../../components/boundary";
3941 import { Duration, LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog, useNow } from "../../components/actions";
4042 import { Markdown } from "../../components/markdown";
4143 import { RunGraph } from "../../components/run-graph";
7375 // An earlier attempt, when one is asked for.
7476 const asked = Number(new URL(request.url).searchParams.get("attempt") ?? "");
7577 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.
7680 const [detail, kept, summaries] = await Promise.all([
7781 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+ ),
8387 ]);
8488 // Artifacts an older runner kept in KV: asked for only once the service
8589 // 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;
8792 // Cancelling and re-running need Write.
8893 return {
8994 detail,
986991 </div>
987992 <div className="min-w-0 space-y-1.5">
988993 <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+ )}
9981007 </div>
9991008 </div>
10001009 </section>
10631072 base: string;
10641073 runId: string;
10651074 }) {
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+ }
10661091 if (artifacts.length === 0) return null;
10671092 return (
10681093 <Card asChild className="scroll-mt-20 p-4">
11321157 }: {
11331158 detail: RunData["detail"];
11341159 artifacts: RunData["artifacts"];
1135− summaries: JobSummary[];
1160+ summaries: RunData["summaries"];
11361161 standing: string;
11371162 cancelling: boolean;
11381163 member: boolean;
11421167 }) {
11431168 const { run, jobs } = detail;
11441169 const [namespace = "", name = ""] = base.slice(1).split("/");
1170+ // Each card keeps a failure to itself: the run's card and its logs stay.
11451171 return (
11461172 <section aria-label="Summary" className="min-w-0 space-y-4">
11471173 <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+ )}
11511187 <ArtifactsList artifacts={artifacts} member={member} busy={busy} base={base} runId={run.id} />
11521188 </section>
11531189 );
+18−5
1919 import { badgeMarkdown, badgeUrl } from "../../lib/badge";
2020 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
2121 import { computeNoteFor } from "../../lib/compute.server";
22+import { isFailed, part } from "../../lib/partial";
23+import { Unavailable } from "../../components/boundary";
2224 import { actions } from "../../lib/services.server";
2325 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
2426 import { accessFor, refusal, repoFor } from "../../lib/access.server";
4042 const member = can?.run ?? false;
4143 // Who may open the pull request that adds a starter workflow.
4244 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.
4347 const [workflows, runs, computeNote] = await Promise.all([
44− actions.workflows(repo, viewer),
48+ part(actions.workflows(repo, viewer), "Workflows"),
4549 actions.runs(repo, viewer, { workflow: selected, limit: 50 }),
4650 // Jobs run on g1t's machines as the workspace's plan allows, or from
4751 // 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.
4954 member && found.ok
5055 ? computeNoteFor(params.owner, "workflow", !found.value.isPrivate).catch(() => null)
5156 : Promise.resolve(null),
5257 ]);
5358 return {
54− workflows: unwrap(workflows),
59+ workflows: isFailed(workflows) ? workflows : unwrap(workflows),
5560 runs: runs.ok ? runs.value : [],
5661 member,
5762 canPush,
386391 - run: npm test`;
387392
388393 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;
390395 const [search] = useSearchParams();
391396 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;
392400 const workflow = workflows.find((w) => w.id === selected || w.path.endsWith(`/${selected}`)) ?? null;
393401 useLiveWhile(runs.some((run) => run.status !== "completed"));
394402 const started = actionData && "started" in actionData ? actionData.started : null;
417425 </div>
418426 )}
419427
420− {workflows.length === 0 ? (
428+ {!workflowsFailed && workflows.length === 0 ? (
421429 <div className="grid gap-6 lg:grid-cols-2">
422430 <div className="lg:col-span-2">
423431 <AddCiPrompt owner={params.owner} repo={params.repo} canAdd={canPush} />
442450 >
443451 All workflows
444452 </Link>
453+ {workflowsFailed && (
454+ <div className="px-2.5 py-1.5">
455+ <Unavailable what="Workflows" compact />
456+ </div>
457+ )}
445458 {workflows.map((w) => (
446459 <Link
447460 key={w.id}
+17−6
1212 import { requireRepo } from "../../lib/access.server";
1313 import { ACTIVITY_TYPES, type ActivityLine, aboutBranch, activityLine } from "../../lib/about";
1414 import { page } from "../../lib/meta";
15+import { isFailed, part } from "../../lib/partial";
16+import { Unavailable } from "../../components/boundary";
1517 import { events, identity } from "../../lib/services.server";
1618 import { UserAvatar, UserCard } from "../../components/user-card";
1719
2830 // One branch's activity (the Branches page links here): each page of
2931 // events is read whole and only that branch's lines are kept.
3032 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;
3438 const lines = found.flatMap((event) => {
3539 const line = activityLine(event);
3640 return line && (!only || aboutBranch(line, only, repo.defaultBranch)) ? [{ id: event.id, time: event.time, actor: event.actor, line }] : [];
3842 // Who: people by their name now; g1t as itself, and as a workspace
3943 // acting under its own identity (lib/mission.ts), never named after it.
4044 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;
4248 const g1t = (actor: string | null) => actor === "g1t" || !!actor?.startsWith("wsp_");
4349 return {
50+ failed: null,
51+ namesFailed: isFailed(named) ? named : null,
4452 lines: lines.map((line) => ({ ...line, who: g1t(line.actor) ? "g1t" : line.actor ? (names[line.actor] ?? null) : null })),
4553 next: found.length === PAGE ? found[found.length - 1].id : null,
4654 defaultBranch: repo.defaultBranch,
110118 }
111119
112120 export default function ActivityPage({ loaderData, params }: Route.ComponentProps) {
113− const { lines, next, branch } = loaderData;
121+ const { lines, next, branch, failed, namesFailed } = loaderData;
114122 const base = `/${params.owner}/${params.repo}`;
115123 return (
116124 <div className="space-y-4">
131139 )}
132140 </p>
133141 </div>
134− {lines.length === 0 ? (
142+ {namesFailed && <Unavailable compact what={namesFailed.what} />}
143+ {failed ? (
144+ <Unavailable what={failed.what} />
145+ ) : lines.length === 0 ? (
135146 <EmptyState title={branch ? (next ? "Nothing on this branch here" : "No activity on this branch") : "No activity yet"}>
136147 {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."}
137148 </EmptyState>
+17−9
1010 import { repos, work } from "../../lib/services.server";
1111 import { redirectIfBranchRenamed } from "../../lib/branch-redirect.server";
1212 import { getViewer, unwrap } from "../../lib/session.server";
13+import { isFailed, part } from "../../lib/partial";
14+import { Unavailable } from "../../components/boundary";
1315
1416 export function meta({ params, ...args }: Route.MetaArgs) {
1517 const tags = page(args, { title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` });
2527 const wantsBlame = new URL(request.url).searchParams.has("blame");
2628 const [found, list, checked] = await Promise.all([
2729 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,
3236 ]);
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;
3539 // A branch that was renamed: the same file on its new name.
3640 if (!found.ok && found.error.code === "not_found") await redirectIfBranchRenamed(request, path, viewer, params.ref);
3741 const blob = unwrap(found);
4549 export default function Blob({ loaderData, params }: Route.ComponentProps) {
4650 const { codeowners, blob, html, wantsBlame } = loaderData;
4751 // 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;
4953 const blame = useBlame(wantsBlame ? `/${params.owner}/${params.repo}/blame/${encodeURIComponent(blob.ref)}/${encodePath(blob.path)}` : null);
5054 return (
5155 <BlobView
5458 blame={blame === null ? null : typeof blame === "string" ? blame : { blame, lines: html }}
5559 branches={loaderData.branches}
5660 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}`} />
5967 )
6068 }
6169 marked={own ? codeowners.errors.map((error) => error.line).filter((line) => line > 0) : undefined}
+21−8
2626 import { useRepoLive } from "../../lib/use-repo-live";
2727 import { commitChecksFor } from "../../lib/commit-checks.server";
2828 import { page } from "../../lib/meta";
29+import { isFailed, orElse, part } from "../../lib/partial";
30+import { Unavailable } from "../../components/boundary";
2931 import { deployments, repos, work } from "../../lib/services.server";
3032 import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
3133
4951 const { viewer, repo, access } = await requireRepo(context, params, "read");
5052 const path = { namespace: params.owner, name: params.repo };
5153 const query = url.searchParams.get("q")?.trim().toLowerCase() || null;
52− const soft = <T,>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null);
5354 // Admins are told when the default branch has no rules at all.
5455 const canProtect = access.can.manage_protection && !repo.archivedAt;
5556 const [list, open, closed, deploys, rules] = await Promise.all([
5657 repos.branches(path, viewer),
57− soft(work.listPulls(path, viewer, "open")),
58+ part(work.listPulls(path, viewer, "open"), "Open pull requests"),
5859 // 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"),
6061 // 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),
6365 ]);
6466 // The merge queue's own branches (g1t-queue/<entry>) are its working
6567 // copies, not anyone's branch to look at.
7173 {
7274 defaultBranch: repo.defaultBranch,
7375 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") : [],
7678 },
7779 BRANCHES_READ,
7880 );
101103 },
102104 // No rules hold for it, and it is not protected the older way.
103105 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),
104108 };
105109 }
106110
137141 }
138142
139143 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;
141145 const base = `/${params.owner}/${params.repo}`;
142146 const navigate = useNavigate();
143147 // New, moved and deleted branches show as they happen (lib/use-repo-live.ts).
230234 </p>
231235 )}
232236
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+
233246 {tab === "overview" && (
234247 <section aria-labelledby="branches-default" className="space-y-2">
235248 <h3 id="branches-default" className="text-sm font-medium text-muted">
+31−14
55 import type { AnnotationLevel, CheckAnnotation, CommitCheckRun } from "@g1t/contracts";
66
77 import type { Route } from "./+types/check-run";
8+import { Boundary, Unavailable } from "../../components/boundary";
89 import { CheckStateIcon } from "../../components/commit-checks";
910 import { Markdown } from "../../components/markdown";
1011 import { EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
1415 import { checkDetail, took } from "../../lib/commit-checks";
1516 import { formatLineHash } from "../../lib/line-anchor";
1617 import { page } from "../../lib/meta";
18+import { isFailed, part } from "../../lib/partial";
1719 import { work } from "../../lib/services.server";
1820 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
1921
2729 const path = { namespace: params.owner, name: params.repo };
2830 const [run, annotations, access] = await Promise.all([
2931 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"),
3134 accessTo(context, params),
3235 ]);
3336 const found = unwrap(run);
3538 if (found.workflow) throw redirect(found.htmlUrl);
3639 return {
3740 run: found,
38− annotations: annotations.ok ? annotations.value : [],
41+ annotations: isFailed(annotations) ? annotations : annotations.ok ? annotations.value : [],
3942 // Pressing its buttons and asking for it again: Write and up.
4043 canReport: access.can.push,
4144 };
234237 </Card>
235238
236239 {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>
248254 ) : (
249255 <EmptyState title={run.status === "completed" ? "No report" : "No report yet"}>
250256 {run.app.name} {run.status === "completed" ? "said nothing more about this run." : "has not said anything more yet."}
251257 </EmptyState>
252258 )}
253259
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+ )}
255272 </div>
256273 );
257274 }
+25−8
1313 import { forkSourceFor } from "../../lib/forks.server";
1414 import { repoPages } from "../../lib/repo-live";
1515 import { useRepoLive } from "../../lib/use-repo-live";
16+import { isFailed, part } from "../../lib/partial";
17+import { Unavailable } from "../../components/boundary";
1618
1719 export function meta({ params, ...args }: Route.MetaArgs) {
1820 return page(args, { title: `Code · ${params.owner}/${params.repo} · g1t` });
2325 const viewer = getViewer(context);
2426 const [tree, branches, environments, fork] = await Promise.all([
2527 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"),
2831 // For the About sidebar's Deployments panel; null without any.
29− environmentsFor(path, viewer),
32+ part(environmentsFor(path, viewer), "Deployments"),
3033 // 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"),
3235 ]);
3336 // Each entry's last commit walks history: streamed in after the list.
3437 const lastCommits = lastCommitsFor(path, viewer, null, "");
3942 const about = value.head ? aboutFor(path, viewer, value.repo.id) : null;
4043 // The latest commit's checks: streamed in beside it.
4144 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+ };
4354 }
4455
4556 export default function Code({ loaderData }: Route.ComponentProps) {
4657 const project = useProject();
4758 // The files and latest commit follow pushes to the branch; the latest
4859 // 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;
5063 useRepoLive({ match: repoPages.files({ branch: tree.ref, branches: branches?.map((branch) => branch.name), sha: tree.head?.hash }) });
5164 return (
5265 <TreeView
5871 canPush={Boolean(project?.access.can.push)}
5972 deployments={loaderData.deployments}
6073 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) ? (
6279 <ForkBar
6380 base={`/${tree.repo.namespace}/${tree.repo.name}`}
6481 branch={tree.ref}
65− parent={loaderData.fork}
82+ parent={fork}
6683 canPush={Boolean(project.access.can.push)}
6784 />
6885 ) : null
+32−11
2828 import { repos, work } from "../../lib/services.server";
2929 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
3030 import { accessTo } from "../../lib/access.server";
31+import { Unavailable } from "../../components/boundary";
32+import { isFailed, part } from "../../lib/partial";
3133
3234 export function headers({ loaderHeaders }: Route.HeadersArgs) {
3335 return { "Server-Timing": loaderHeaders.get("Server-Timing") ?? "" };
5759 }
5860 const started = Date.now();
5961 // 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.
6166 const said = Promise.all([
62− work.commitComments(viewer, path, hash).catch(() => null),
67+ part(work.commitComments(viewer, path, hash), "Comments"),
6368 work.getSettings(path, viewer).catch(() => null),
6469 accessTo(context, params).catch(() => null),
65− work.closedByCommit(path, hash, viewer).catch(() => null),
70+ part(work.closedByCommit(path, hash, viewer), "The issues it closed"),
6671 ]);
6772 const found = unwrap(await repos.get(path, viewer));
6873 // The viewer may read the repository, and a commit never changes: after
95100 comparison: firstScreens(loaded.comparison),
96101 commit,
97102 // 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"),
99104 // Never cached: checks report on a commit long after it was made.
100105 checks: commitChecksFor(path, viewer, [loaded.commit.hash]),
101− comments: comments?.ok ? comments.value : [],
106+ comments: isFailed(comments) ? comments : comments.ok ? comments.value : [],
102107 // Off, the comments made stay readable and no more can be added.
103108 commentsOn: settings?.ok ? settings.value.allowCommitComments : true,
104109 // 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 : [],
106111 viewer: viewer ? { username: viewer.username } : null,
107112 // Maintain and up edit and delete anyone's comments; everyone, their own.
108113 canModerate: access?.can.manage_settings ?? false,
200205
201206 export default function CommitPage({ loaderData, actionData, params }: Route.ComponentProps) {
202207 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);
204210 const general = said.filter((comment) => !comment.path);
205211 const base = `/${params.owner}/${params.repo}`;
206212 const { subject, body, trailers } = parseCommitMessage(commit.message);
281287 </div>
282288 }
283289 >
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>
285301 </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+ )}
289309 </section>
290310 </Card>
291311 <div className="mt-6">
307327 </span>
308328 )}
309329 </h3>
330+ {isFailed(comments) && <Unavailable what={comments.what} />}
310331 <CommentList
311332 comments={general}
312333 base={base}
+14−6
2222 import { useRepoLive } from "../../lib/use-repo-live";
2323 import { getViewer, unwrap } from "../../lib/session.server";
2424 import { useProject } from "./layout";
25+import { Unavailable } from "../../components/boundary";
26+import { isFailed, part } from "../../lib/partial";
2527
2628 const PAGE_SIZE = 50;
2729
3739 const checks = commitChecksFor(path, viewer, log.map((commit) => commit.hash));
3840 // Who wrote each commit, as their account where there is one.
3941 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+ );
4548 return { commits, checks, comments };
4649 }
4750
98101 return (
99102 <>
100103 <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>
101109 <div className="relative space-y-8 before:absolute before:top-3 before:bottom-3 before:left-1.75 before:w-px before:bg-line">
102110 {byDay(commits).map(([day, list]) => (
103111 <section key={day} className="relative pl-7">
124132 <CommitChecksBadge checks={checks} sha={commit.hash} />
125133 <Suspense fallback={null}>
126134 <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`} />)}
128136 </Await>
129137 </Suspense>
130138 </div>
+23−8
1515 import { showCommits } from "../../lib/commit-people.server";
1616 import { repos } from "../../lib/services.server";
1717 import { getViewer, unwrap } from "../../lib/session.server";
18+import { Unavailable } from "../../components/boundary";
19+import { type Failed, isFailed, part } from "../../lib/partial";
1820
1921 /** How far back each side's history is read to find what head has that base does not. */
2022 const HEAD_DEPTH = 100;
4244 const head = url.searchParams.get("head") ?? "";
4345 return Response.redirect(new URL(`/${params.owner}/${params.repo}/compare/${base}...${head}`, url).toString(), 302);
4446 }
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")]);
4650 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/"));
5056 const asked = sides(params["*"] ?? "");
5157 const base = asked.base ?? found.defaultBranch;
5258 const head = asked.head;
6874 // bring them: what base gained since is not head taking it away. When
6975 // that point is past what was read, the two tips are compared.
7076 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");
7279 // 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;
7485 return { base, head, branches, comparison, commits, same: false };
7586 }
7687
98109 }
99110
100111 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;
102114 const repoBase = `/${params.owner}/${params.repo}`;
103115 return (
104116 <div className="space-y-5">
129141 )}
130142 </Form>
131143 </Card>
144+ {isFailed(loaderData.branches) && <Unavailable what={loaderData.branches.what} compact />}
132145
133146 {!head ? (
134147 <EmptyState title="Pick a branch to compare">Its commits and changes against {base} show here.</EmptyState>
167180 </ul>
168181 </Card>
169182 </section>
170− {comparison ? (
183+ {isFailed(comparison) ? (
184+ <Unavailable what={comparison.what} />
185+ ) : comparison ? (
171186 <DiffView comparison={comparison} repo={repoBase} fileBase={`${repoBase}/blob/${encodeURIComponent(head)}`} empty="The two sides have the same files." />
172187 ) : (
173188 <p className="text-sm text-muted">The changes could not be read just now. The commits above are what {head} adds.</p>
+42−10
66
77 import type { Route } from "./+types/deployment";
88 import { page } from "../../lib/meta";
9+import { isFailed, part } from "../../lib/partial";
10+import { Boundary, Unavailable } from "../../components/boundary";
911 import { TimeAgo } from "../../components/ui";
1012 import { Badge } from "../../components/ui/badge";
1113 import { Card } from "../../components/ui/card";
2426 const viewer = getViewer(context);
2527 const { repo } = await requireRepo(context, params, "read");
2628 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));
2929 // A g1t.page build also has its build log and warnings, for the workspace's members.
3030 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,
3333 ]);
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+ };
3851 }
3952
4053 /** A g1t.page build's status in words. */
4962 } as const;
5063
5164 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;
5367 const base = `/${params.owner}/${params.repo}`;
5468 return (
5569 <div>
5872 Deployments
5973 </Link>
6074 {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+ )}
6294 </div>
6395 );
6496 }
+100−36
1919 import { DeployLink, host } from "../../components/deploy";
2020 import { DeploymentStateBadge, DeploymentStateIcon, sourceLabel } from "../../components/deployments-panel";
2121 import { FilterMenu } from "../../components/labels";
22+import { Boundary, Unavailable } from "../../components/boundary";
2223
2324 import type { Route } from "./+types/deployments";
2425 import { page } from "../../lib/meta";
2728 import { Card } from "../../components/ui/card";
2829 import { Hint } from "../../components/ui/hint";
2930 import { computeNoteFor } from "../../lib/compute.server";
31+import { type Failed, isFailed, orElse, part } from "../../lib/partial";
3032 import { neverDeploys } from "../../lib/project-kind";
3133 import { billing, deployments, projects } from "../../lib/services.server";
3234 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
6264 const path = { namespace: repo.namespace, name: repo.name };
6365 const ref = { workspace: params.owner, slug: params.repo };
6466 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.
6869 // g1t.page's settings and builds are the workspace's members' alone.
6970 const insider = access.insider;
7071 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,
7677 // 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"),
7980 ]);
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;
8286 // 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)
8793 : 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;
88104 return {
89105 can: access.can,
90106 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) }
97111 : null,
112+ list: isFailed(list) ? list : list.ok ? list.value : null,
113+ pages,
98114 };
99115 }
100116
126142 }
127143
128144 type Loaded = Route.ComponentProps["loaderData"];
129−type Pages = NonNullable<Loaded["pages"]>;
145+type Pages = Exclude<NonNullable<Loaded["pages"]>, Failed>;
130146
131147 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);
133156 const base = `/${params.owner}/${params.repo}`;
134157 const total = environments?.total_count ?? list?.total_count ?? 0;
135158 const envs = environments?.environments ?? [];
136159 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;
139164 const production = envs.find((env) => env.production_environment && environmentUrl(env));
140165 const productionUrl = production ? environmentUrl(production) : null;
141166 const planNeeded = pages?.plan != null && !pages.plan.on && !pages.plan.included && pages.live.length === 0;
173198 {!actionData && pages && <ComputeNote note={pages.computeNote} />}
174199 </div>
175200
176− {!environments && !list ? (
201+ {environmentsFailed && listFailed ? (
202+ <Unavailable what="Deployments" />
203+ ) : !environments && !list && !environmentsFailed && !listFailed ? (
177204 <Card asChild className="p-6 text-sm text-muted">
178205 <p>
179206 Deployments could not be loaded just now. Reload the page to try again.
183210 <Start base={base} pages={pages} can={can} owner={params.owner} planNeeded={planNeeded} />
184211 ) : (
185212 <>
186− {envs.length > 0 && (
213+ {environmentsFailed ? (
187214 <section aria-labelledby="environments">
188215 <h2 id="environments" className="text-sm font-semibold">
189− Environments <span className="font-normal text-faint">{envs.length}</span>
216+ Environments
190217 </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>
196221 </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+ )
197237 )}
198238
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+ )}
200253 </>
201254 )}
202255
203256 {/* 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+ )}
204268 {any && pages && (
205269 <section aria-labelledby="g1t-page" className="mt-12 border-t border-line pt-8">
206270 <h2 id="g1t-page" className="flex items-center gap-2 text-sm font-semibold">
391455 environments,
392456 }: {
393457 base: string;
394− list: Loaded["list"];
458+ list: Exclude<Loaded["list"], Failed>;
395459 filter: ListFilter;
396460 environments: DeploymentEnvironment[];
397461 }) {
+8−2
77 import { Card } from "../../components/ui/card";
88 import { requireRepo } from "../../lib/access.server";
99 import { page } from "../../lib/meta";
10+import { isFailed, part } from "../../lib/partial";
11+import { Unavailable } from "../../components/boundary";
1012 import { projects, repos } from "../../lib/services.server";
1113 import { unwrap } from "../../lib/session.server";
1214
2123 const { viewer, repo } = await requireRepo(context, params, "read");
2224 const found = unwrap(await repos.forks({ namespace: params.owner, name: params.repo }, viewer, LISTED));
2325 // 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;
2529 return {
30+ descriptionsFailed: isFailed(described) ? described : null,
2631 forks: found.map((fork) => ({
2732 namespace: fork.namespace,
2833 name: fork.name,
3742 }
3843
3944 export default function Forks({ loaderData, params }: Route.ComponentProps) {
40− const { forks, forkable, signedIn } = loaderData;
45+ const { forks, forkable, signedIn, descriptionsFailed } = loaderData;
4146 const base = `/${params.owner}/${params.repo}`;
4247 return (
4348 <div className="space-y-4">
5661 </ButtonLink>
5762 )}
5863 </div>
64+ {descriptionsFailed && forks.length > 0 && <Unavailable compact what={descriptionsFailed.what} />}
5965 {forks.length === 0 ? (
6066 <EmptyState title="No forks yet">
6167 {forkable
+32−18
1313 import { integrations, work } from "../../lib/services.server";
1414 import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
1515 import { accessTo } from "../../lib/access.server";
16+import { Unavailable } from "../../components/boundary";
17+import { isFailed, part } from "../../lib/partial";
1618
1719 export function meta({ params, ...args }: Route.MetaArgs) {
1820 return page(args, { title: `New issue · ${params.owner}/${params.repo} · g1t` });
2224 const user = requireUser(context, request);
2325 const path = { namespace: params.owner, name: params.repo };
2426 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.
2529 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),
2934 ]);
3035 // Systems a ticket can be imported from.
31− const sources = connections.ok
36+ const sources = connections?.ok
3237 ? [...new Set(connections.value.filter((c) => c.kind === "tracker" || c.provider === "sentry").map((c) => c.provider))]
3338 : [];
3439 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 : [],
3742 // Making labels, and choosing a milestone, need Triage.
3843 canTriage: can.triage,
3944 // Making labels needs Write; choosing them and a milestone, Triage.
123128 </Field>
124129 <fieldset>
125130 <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+ )}
137146 {loaderData.canMakeLabels && (
138147 <div className="mt-2">
139148 <Input
144153 </div>
145154 )}
146155 </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 && (
148162 <Field label="Milestone">
149163 <SelectField
150164 name="milestone"
+118−55
4141 import { useRepoLive } from "../../lib/use-repo-live";
4242 import { DecisionList, IntegrationFailure, IssueWorkPanel, StartSessionDialog, TimelineSession } from "../../components/coordination";
4343 import { Reactions } from "../../components/reactions";
44+import { Boundary, Unavailable } from "../../components/boundary";
45+import { isFailed, part } from "../../lib/partial";
4446 import { parseBlockedBy, resolutionOf } from "../../lib/coordination";
4547
4648
6567 const path = { namespace: params.owner, name: params.repo };
6668 const number = Number(params.number);
6769 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.
6873 const [{ can }, found, labels, milestones, members, links, subscription, teams, coordination, ended, sessionTrees, agentSetup] = await Promise.all([
6974 access,
7075 work.getIssue(path, number, viewer),
71− work.listLabels(path, viewer),
76+ part(work.listLabels(path, viewer), "Labels"),
7277 // 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"),
7479 // 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,
7681 // 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"),
7883 // Whether the viewer hears of it, for the sidebar's Notifications.
7984 viewer
8085 ? 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,
8287 )
8388 : 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.
8591 roleIn(viewer, params.owner) ? identity.listTeams(viewer, params.owner).catch(() => null) : null,
8692 // Who is on it, its sessions, what it waits on and its decisions
8793 // (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.
9097 Promise.all(
9198 (["released", "expired", "handed_off"] as const).map((state) =>
9299 work.listClaims(path, viewer, { state, issue: number }).catch(() => null),
93100 ),
94101 ),
95102 // 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.
98106 roleIn(viewer, params.owner) ? agentSessions.setup(params.owner, viewer).catch(() => null) : null,
99107 ]);
100108 if (!found.ok) {
107115 return {
108116 ...found.value,
109117 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 : [],
112120 links,
113121 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,
116124 // Only when it can start: the button is not shown otherwise.
117125 agentSetup: agentSetup?.ok && agentSetup.value.available ? agentSetup.value : null,
118126 endedClaims: ended
119127 .flatMap((claims) => (claims?.ok ? claims.value : []))
120128 .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) : [],
122130 handles: teams?.ok ? teams.value.map((team) => `@${team.workspace}/${team.slug}`) : [],
123131 // The author can close and reopen their own issue, and whoever g1t's
124132 // agent filed one for, that one; Triage and up, anyone's.
313321 }
314322
315323 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;
317328 // Follow pull requests at work without a manual reload.
318329 const running = pulls.some(
319330 (pull) =>
370381 </span>
371382 </span>
372383 {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} />
374386 ))}
375387 {open && issue.agent && <Assignee agent={issue.agent} />}
376388 </div>
462474 <IntegrationFailure className="mt-4" failure={failure} base={base} fixed={resolution?.passed ?? resolution?.fix} />
463475 )}
464476
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 && (
466485 <>
467486 <div className="mt-10 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
468487 <h3 className="font-semibold tracking-tight">Decisions</h3>
477496 <h3 className="mt-10 font-semibold tracking-tight">Discussion</h3>
478497 <div className="mt-3">
479498 <div className="space-y-4">
499+ {isFailed(loaderData.sessionTrees) && <Unavailable what="Its sessions" compact />}
480500 <CommentList
481501 comments={comments}
482502 base={base}
483503 viewerId={viewer?.id ?? null}
484504 viewerName={viewer?.username ?? null}
485− extra={(loaderData.sessionTrees ?? [])
505+ extra={(sessionTrees ?? [])
486506 .filter((session) => !session.parentId)
487507 .map((session) => ({ id: session.id, at: session.startedAt, node: <TimelineSession session={session} base={base} /> }))}
488508 canModerate={loaderData.canModerate}
526546 [issue.milestone ? 1 : 0, "milestone", "milestones"],
527547 ])}
528548 >
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>
547576 )}
548− {loaderData.links.length > 0 && (
577+ {isFailed(loaderData.links) ? (
549578 <section>
550579 <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>
551587 <ul className="mt-2 space-y-1.5 text-sm">
552588 {loaderData.links.map((link) => (
553589 <li key={`${link.connectionId}-${link.key}`}>
641677 </summary>
642678 <Form method="post" className="mt-2 space-y-2" key={issue.assignees.join()}>
643679 <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+ )}
645685 <SubmitButton variant="outline" name="who" value="picked" pending="Saving…">
646686 Save assignees
647687 </SubmitButton>
697737 </Card>
698738 )}
699739
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+ />
716760 )}
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+ ))}
717768 </DetailsDisclosure>
718769 </div>
719770 );
720771 }
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+}
+12−7
1818 import { getViewer, unwrap } from "../../lib/session.server";
1919 import { repoPages } from "../../lib/repo-live";
2020 import { useRepoLive } from "../../lib/use-repo-live";
21+import { isFailed, part } from "../../lib/partial";
2122
2223 export function meta({ params, ...args }: Route.MetaArgs) {
2324 return page(args, { title: `Issues · ${params.owner}/${params.repo} · g1t` });
2930 // `?label=bug&milestone=3`, or `label:bug milestone:3` written in `q`.
3031 const { state, label, milestone } = listFilters(new URL(request.url).searchParams);
3132 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"),
3436 ]);
3537 return {
3638 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 : [],
3941 state,
4042 label,
4143 milestone,
4345 }
4446
4547 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;
4752 // Issues opened, commented on and closed show as it happens.
4853 useRepoLive({ match: repoPages.issues() });
4954 const repo = `/${params.owner}/${params.repo}`;
8186 active={label || undefined}
8287 clearTo={label ? withFilter(base, current, "label", null) : undefined}
8388 searchPlaceholder="Filter labels"
84− emptyText="No label matches."
89+ emptyText={isFailed(loaderData.labels) ? "Labels could not be loaded just now." : "No label matches."}
8590 options={labels.map((each) => ({
8691 key: each.name,
8792 to: withFilter(base, current, "label", each.name),
100105 active={milestoneTitle ?? undefined}
101106 clearTo={milestone ? withFilter(base, current, "milestone", null) : undefined}
102107 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."}
104109 options={milestones.map((each) => ({
105110 key: String(each.number),
106111 to: withFilter(base, current, "milestone", String(each.number)),
+4−2
2121 import { IssueIcon, PullIcon } from "../../components/work";
2222 import { requireRepo } from "../../lib/access.server";
2323 import { colorsOf } from "../../lib/labels";
24+import { orElse, part } from "../../lib/partial";
2425 import { page } from "../../lib/meta";
2526 import { milestoneAction } from "../../lib/milestones.server";
2627 import { openedBy } from "../../lib/opened-by";
3738 const state = new URL(request.url).searchParams.get("state") === "closed" ? "closed" : "open";
3839 const [found, labels] = await Promise.all([
3940 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)),
4143 ]);
4244 if (!found.ok) throw new Response("Milestone not found.", { status: 404 });
4345 return {
4446 detail: found.value,
45− labels: labels.ok ? labels.value : [],
47+ labels: labels?.ok ? labels.value : [],
4648 state,
4749 canEdit: access.can.manage_labels,
4850 today: new Date().toISOString(),
+18−2
312312 claims: ok(claims) ?? [],
313313 sessions: [...(ok(active) ?? []), ...(ok(idle) ?? [])],
314314 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,
315321 titles,
316322 livePulls,
317323 defaultBranch,
887893
888894 <div className="grid gap-x-8 gap-y-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
889895 <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 && (
891902 <Panel title="Needs you" icon={<Hand size={14} className="text-warn" />} count={needs.length}>
892903 <NeedsList needs={needs} limit={5} />
893904 </Panel>
970981
971982 <aside className="space-y-4 text-sm">
972983 {/* 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 />}
974990 {loaderData.telemetry && <ObservabilityCard overview={loaderData.telemetry.overview} errors={loaderData.telemetry.errors} base={base} />}
975991 {project && <AboutCard project={project} canChange={canChange} base={base} release={loaderData.release} />}
976992 <HealthCard loaderData={loaderData} base={base} plan={plan} />
+47−15
Loading the diff…
+165−65
Loading the diff…
+12−7
Loading the diff…
+6−2
Loading the diff…
+25−8
Loading the diff…
+3−1
Loading the diff…
+33−7
Loading the diff…
+48−25
Loading the diff…

Comments

Sign in to comment.