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 settings, security and observability pages fail one part at a time, and never say something false because a read failed. Settings: Add team ("No other team to add" was what a failure said); the default branch's rules (a failure said no rules held for it and anyone could push); General's Branches, Artifacts, Features and Commits sections show Unavailable instead of forms that could save over values never read (Branches and retention used to fail the page); Rules' "what holds for a branch" and Insights. Security: the overview's code scanning, policy and advisories rows say Unavailable instead of guessing "None yet" or "Needs setup", and Open alerts says when code scanning is not in its counts; custom patterns (a failure showed the sales prompt); a secret's validity check (a failure said checks were off); whether reporting is on is unknown, not off. Observability: the overview, open errors ("No open errors in this period"), the g1t.page switch, retention, exports, usage, tokens and a metric's chart each say so in place; the lists use Unavailable. Kept quiet on purpose: the creator's name, the deployments-on preview warning, checks seen for offering CI and in a ruleset's suggestions, a security row's fix status, filter facets (a filter in the address still applies). Checked: apps/web tsc -b; npm test (987, 1 skipped).

G1t-Session:
ses_01m4zzpc96ect8d41mffgr1a45
syntaqxcommitted Parentf67fb78Browse files
17 files+401−2000/17 viewed
+2−1
1414 import { page } from "../../lib/meta";
1515 import { count, errorIssue, viewHref } from "../../lib/observability";
1616 import { observed, soft } from "../../lib/observability.server";
17+import { Unavailable } from "../../components/boundary";
1718 import { work } from "../../lib/services.server";
1819 import { assertSameOrigin, requireUser } from "../../lib/session.server";
1920
9899
99100 <div className={cn("mt-2 transition-opacity", loading && "opacity-60")}>
100101 {!groups ? (
101− <Card className="p-6 text-sm text-muted">Errors could not be loaded just now. Reload the page to try again.</Card>
102+ <Unavailable what="Errors" />
102103 ) : groups.length === 0 ? (
103104 <EmptyState title={state === "open" ? "No open errors in this period" : `No ${state} errors in this period`}>
104105 Exceptions thrown by apps on g1t.page show here on their own; others arrive as OpenTelemetry exception events and log records.
+2−1
1515 import { matchesLog, viewHref } from "../../lib/observability";
1616 import { useTelemetryLive } from "../../lib/observability-live";
1717 import { observed, soft } from "../../lib/observability.server";
18+import { Unavailable } from "../../components/boundary";
1819
1920 /** The levels offered as toggles: trace and debug go with debug, fatal with error. */
2021 const LEVELS: LogSeverity[] = ["debug", "info", "warn", "error"];
125126
126127 <div className={cn("mt-4 transition-opacity", loading && "opacity-60")}>
127128 {!list ? (
128− <Card className="p-6 text-sm text-muted">Logs could not be loaded just now. Reload the page to try again.</Card>
129+ <Unavailable what="Logs" />
129130 ) : shown.length === 0 ? (
130131 <EmptyState title={view.q || levels.length ? "No lines match" : "No logs in this period"}>
131132 {live ? "Following: new lines show here as they are written." : "Apps on g1t.page send what they write with console.log; anything else sends OpenTelemetry logs."}
+8−4
99 import { page } from "../../lib/meta";
1010 import { duration, viewHref } from "../../lib/observability";
1111 import { observed, soft } from "../../lib/observability.server";
12+import { Unavailable } from "../../components/boundary";
13+import { isFailed, part } from "../../lib/partial";
1214 import { PRODUCT_STYLE } from "../../lib/usage";
1315
1416 const AGGREGATES = ["avg", "sum", "min", "max", "p50", "p95", "p99"] as const;
2628 const info = metrics?.find((m) => m.name === name) ?? null;
2729 const aggregate = AGGREGATES.find((a) => a === search.get("agg")) ?? (info?.kind === "sum" ? "sum" : "avg");
2830 const groupBy = search.get("by") || null;
29− const series = name ? await soft(service.metric({ project_id: projectId, ...filter, name, aggregate, group_by: groupBy }), "metric") : null;
31+ // The chosen metric's chart says so in its place when it could not be read.
32+ const series = name ? await part(service.metric({ project_id: projectId, ...filter, name, aggregate, group_by: groupBy }), `${name}'s points`) : null;
3033 return { view, metrics, facets, name, info, aggregate, groupBy, series };
3134 }
3235
5558
5659 <div className={cn("mt-4 transition-opacity", loading && "opacity-60")}>
5760 {!metrics ? (
58− <Card className="p-6 text-sm text-muted">Metrics could not be loaded just now. Reload the page to try again.</Card>
61+ <Unavailable what="Metrics" />
5962 ) : metrics.length === 0 ? (
6063 <EmptyState title="No metrics yet">
6164 Request counts, errors and latency are on the Overview already. Metrics of your own arrive from an OpenTelemetry SDK's metric exporter,
114117 />
115118 </div>
116119 )}
117− {series && (
120+ {isFailed(series) && <Unavailable what={series.what} />}
121+ {series && !isFailed(series) && (
118122 <TimeChart
119123 title={`${series.name} (${aggregate})`}
120124 kind="lines"
130134 empty="No points in this period."
131135 />
132136 )}
133− {series && series.series.length > PRODUCT_STYLE.length && (
137+ {series && !isFailed(series) && series.series.length > PRODUCT_STYLE.length && (
134138 <p className="mt-2 text-xs text-faint">Showing the first {PRODUCT_STYLE.length} of {series.series.length} groups.</p>
135139 )}
136140 </div>
+2−1
1010 import { page } from "../../lib/meta";
1111 import { clock, duration, shortId, viewHref } from "../../lib/observability";
1212 import { observed, soft } from "../../lib/observability.server";
13+import { Unavailable } from "../../components/boundary";
1314
1415 const STATUSES = ["2xx", "3xx", "4xx", "5xx"] as const;
1516
9495
9596 <div className={cn("mt-4 transition-opacity", loading && "opacity-60")}>
9697 {!list ? (
97− <Card className="p-6 text-sm text-muted">Requests could not be loaded just now. Reload the page to try again.</Card>
98+ <Unavailable what="Requests" />
9899 ) : list.items.length === 0 ? (
99100 <EmptyState title={cursor ? "No older requests" : "No requests match"}>
100101 Requests of apps on g1t.page arrive within seconds of being answered; others arrive with their server spans.
+50−26
3030 import { Textarea } from "../../components/ui/textarea";
3131 import { page } from "../../lib/meta";
3232 import { bytes, count, otelEnv } from "../../lib/observability";
33−import { observed, soft } from "../../lib/observability.server";
33+import { observed } from "../../lib/observability.server";
34+import { Unavailable } from "../../components/boundary";
35+import { isFailed, part } from "../../lib/partial";
3436 import { assertSameOrigin, requireUser } from "../../lib/session.server";
3537
3638 export function meta({ params, ...args }: Route.MetaArgs) {
4143 const { projectId, service, project } = await observed(context, params, request, "manage_integrations");
4244 // The workspace goes along, so the project's store knows whose free cap applies.
4345 const ref = { project_id: projectId, workspace: (params.owner ?? "").toLowerCase() };
46+ // Each is a section of its own, which says so in its place when it could
47+ // not be read; a form whose values could not be read is not shown, so
48+ // nothing is saved over them.
4449 const [settings, tokens, exports, usage] = await Promise.all([
45− soft(service.settings(ref), "settings"),
46− soft(service.tokens(ref), "tokens"),
47− soft(service.exports(ref), "exports"),
48− soft(service.usage(ref), "usage"),
50+ part(service.settings(ref), "These settings"),
51+ part(service.tokens(ref), "Ingest tokens"),
52+ part(service.exports(ref), "Exports"),
53+ part(service.usage(ref), "This month's usage"),
4954 ]);
5055 return { settings, tokens, exports, usage, slug: project.slug };
5156 }
132137 <CopyLine text={TELEMETRY_OTLP_ENDPOINT} />
133138 <pre className="overflow-x-auto rounded-lg border border-line bg-surface p-3 font-mono text-xs">{otelEnv(TELEMETRY_OTLP_ENDPOINT, slug, "g1to_…")}</pre>
134139 </div>
135− {settings && (
140+ {isFailed(settings) ? (
141+ <div className="mt-4">
142+ <Unavailable what="Whether apps on g1t.page report here" compact />
143+ </div>
144+ ) : (
136145 <Form method="post" className="mt-4">
137146 <SwitchCard
138147 title="Apps on g1t.page report here"
157166 For software running anywhere other than g1t.page: a token lets it send to this project and do nothing else. Each is shown once, when
158167 it is made. Apps on g1t.page need none; g1t gives them their own.
159168 </p>
160− {!tokens ? (
161− <Card className="mt-3 p-4 text-sm text-muted">Tokens could not be loaded just now.</Card>
169+ {isFailed(tokens) ? (
170+ <div className="mt-3">
171+ <Unavailable what={tokens.what} />
172+ </div>
162173 ) : tokens.length === 0 ? (
163174 <Card className="mt-3 p-4 text-sm text-muted">None yet.</Card>
164175 ) : (
222233 Send everything that arrives on to any OTLP/HTTP endpoint as well: a collector of your own, or any tool that takes OpenTelemetry.
223234 Requests on g1t.page go as server spans, their logs as log records.
224235 </p>
225− {exports && exports.length > 0 && (
236+ {isFailed(exports) && (
237+ <div className="mt-3">
238+ <Unavailable what={exports.what} />
239+ </div>
240+ )}
241+ {!isFailed(exports) && exports.length > 0 && (
226242 <Card className="mt-3 divide-y divide-line">
227243 {exports.map((e) => (
228244 <div key={e.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
277293 )}
278294 </section>
279295
280− {settings && (
281− <section className="mt-10" aria-labelledby="retention-title">
282− <h2 id="retention-title" className="text-sm font-semibold">
283− Retention
284− </h2>
285− <p className="mt-1 text-sm text-muted">
286− How long requests, logs, spans and metric points are kept. The Overview's per-minute numbers are kept for 90 days whatever this is.
287− </p>
296+ <section className="mt-10" aria-labelledby="retention-title">
297+ <h2 id="retention-title" className="text-sm font-semibold">
298+ Retention
299+ </h2>
300+ <p className="mt-1 text-sm text-muted">
301+ How long requests, logs, spans and metric points are kept. The Overview's per-minute numbers are kept for 90 days whatever this is.
302+ </p>
303+ {isFailed(settings) ? (
304+ <div className="mt-3">
305+ <Unavailable what="The retention" />
306+ </div>
307+ ) : (
288308 <Form method="post" className="mt-3 flex items-center gap-2">
289309 <input type="hidden" name="intent" value="retention" />
290310 <SelectField
298318 Save
299319 </SubmitButton>
300320 </Form>
301− </section>
302− )}
321+ )}
322+ </section>
303323
304− {usage && (
305− <section className="mt-10" aria-labelledby="usage-title">
306− <h2 id="usage-title" className="text-sm font-semibold">
307− This month
308− </h2>
324+ <section className="mt-10" aria-labelledby="usage-title">
325+ <h2 id="usage-title" className="text-sm font-semibold">
326+ This month
327+ </h2>
328+ {isFailed(usage) ? (
329+ <div className="mt-3">
330+ <Unavailable what={usage.what} />
331+ </div>
332+ ) : (
309333 <Card className="mt-3 grid grid-cols-2 gap-4 p-4 sm:grid-cols-3">
310334 {(
311335 [
324348 </div>
325349 ))}
326350 </Card>
327− </section>
328− )}
351+ )}
352+ </section>
329353 </div>
330354 );
331355 }
+2−1
1010 import { page } from "../../lib/meta";
1111 import { clock, duration, viewHref } from "../../lib/observability";
1212 import { observed, soft } from "../../lib/observability.server";
13+import { Unavailable } from "../../components/boundary";
1314
1415 export function meta({ params, ...args }: Route.MetaArgs) {
1516 return page(args, { title: `Traces · ${params.owner}/${params.repo} · g1t` });
7778
7879 <div className={cn("mt-4 transition-opacity", loading && "opacity-60")}>
7980 {!list ? (
80− <Card className="p-6 text-sm text-muted">Traces could not be loaded just now. Reload the page to try again.</Card>
81+ <Unavailable what="Traces" />
8182 ) : list.items.length === 0 ? (
8283 <EmptyState title={cursor ? "No older traces" : "No traces in this period"}>
8384 Traces come from OpenTelemetry spans. An app on g1t.page sends them as soon as it uses an OpenTelemetry SDK: its endpoint and token
+16−7
1313 import { page } from "../../lib/meta";
1414 import { SERIES, count, duration, percent, shortId, viewHref } from "../../lib/observability";
1515 import { observed, soft } from "../../lib/observability.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: `Observability · ${params.owner}/${params.repo} · g1t` });
2022
2123 export async function loader({ params, context, request }: Route.LoaderArgs) {
2224 const { projectId, service, view, filter, access } = await observed(context, params, request);
25+ // The overview and its open errors each say so in their place when they
26+ // could not be read. The filter's choices quietly go without (soft): a
27+ // filter in the address still applies.
2328 const [overview, facets, errors] = await Promise.all([
24− soft(service.overview({ project_id: projectId, ...filter }), "overview"),
29+ part(service.overview({ project_id: projectId, ...filter }), "Observability"),
2530 soft(service.facets({ project_id: projectId }), "facets"),
26− soft(service.errors({ project_id: projectId, ...filter, state: "open" }), "errors"),
31+ part(service.errors({ project_id: projectId, ...filter, state: "open" }), "Open errors"),
2732 ]);
28− return { view, overview, facets, errors: errors?.slice(0, 5) ?? null, canManage: access.can.manage_integrations };
33+ return { view, overview, facets, errors: isFailed(errors) ? errors : errors.slice(0, 5), canManage: access.can.manage_integrations };
2934 }
3035
3136 export default function Observability({ loaderData, params }: Route.ComponentProps) {
3338 const base = `/${params.owner}/${params.repo}`;
3439 const path = `${base}/observability`;
3540 const loading = useLoadingView();
36− const nothing = overview && overview.totals.requests === 0 && overview.services.length === 0 && (facets?.services.length ?? 0) === 0;
41+ const nothing = !isFailed(overview) && overview.totals.requests === 0 && overview.services.length === 0 && (facets?.services.length ?? 0) === 0;
3742
3843 return (
3944 <div className="mx-auto max-w-6xl">
4449 <FilterBar path={path} view={view} environments={facets?.environments ?? []} services={facets?.services ?? []} />
4550
4651 <div className={cn("mt-6 transition-opacity", loading && "opacity-60")}>
47− {!overview ? (
48− <Card className="p-6 text-sm text-muted">Observability could not be loaded just now. Reload the page to try again.</Card>
52+ {isFailed(overview) ? (
53+ <Unavailable what={overview.what} />
4954 ) : nothing ? (
5055 <NothingYet base={base} canManage={canManage} />
5156 ) : (
187192 All <ArrowRight size={12} className="inline" />
188193 </Link>
189194 </div>
190− {!errors || errors.length === 0 ? (
195+ {isFailed(errors) ? (
196+ <div className="px-4 py-4">
197+ <Unavailable what={errors.what} compact />
198+ </div>
199+ ) : errors.length === 0 ? (
191200 <p className="px-4 py-6 text-sm text-muted">No open errors in this period.</p>
192201 ) : (
193202 <ul className="divide-y divide-line">
+4−1
1919 import { advisories } from "../../lib/services.server";
2020 import { getViewer, unwrap } from "../../lib/session.server";
2121 import { requireRepo } from "../../lib/access.server";
22+import { orElse, part } from "../../lib/partial";
2223 import { ADVISORY_STATES, STATE_LABEL, STATE_TONE, advisoryStanding, parseAdvisoryState } from "../../lib/advisories";
2324
2425 export function meta({ params, ...args }: Route.MetaArgs) {
3031 const { access } = await requireRepo(context, params, "read");
3132 const path = { namespace: params.owner, name: params.repo };
3233 const insider = Boolean(access.insider && access.can.security_alerts);
34+ // How many reports are new is only a count on the Reports link, which
35+ // goes without it when it cannot be read: the Reports page has them all.
3336 const [list, reports] = await Promise.all([
3437 advisories.list(path, viewer),
35− insider ? advisories.reports(path, viewer, "new") : Promise.resolve(null),
38+ insider ? part(advisories.reports(path, viewer, "new"), "New reports").then((found) => orElse(found, null)) : Promise.resolve(null),
3639 ]);
3740 return {
3841 advisories: unwrap(list),
+140−69
3131 import { SEVERITY_LABEL } from "../../lib/advisories";
3232 import { repoPages } from "../../lib/repo-live";
3333 import { useRepoLive } from "../../lib/use-repo-live";
34+import { Unavailable } from "../../components/boundary";
35+import { type Failed, isFailed, part } from "../../lib/partial";
3436
3537 export function meta({ params, ...args }: Route.MetaArgs) {
3638 return page(args, { title: `Security · ${params.owner}/${params.repo} · g1t` });
3739 }
3840
39−/** The security policy on the default branch, if any: the first of the places it may be. */
40−async function policyPath(path: { namespace: string; name: string }, viewer: ReturnType<typeof getViewer>, defaultBranch: string): Promise<string | null> {
41− const found = await Promise.all(POLICY_PATHS.map((file) => repos.blob(path, viewer, defaultBranch, file).catch(() => null)));
42− return POLICY_PATHS.find((_, at) => found[at]?.ok) ?? null;
41+/**
42+ * The security policy on the default branch, if any: the first of the
43+ * places it may be. When none was found but a place could not be read, it
44+ * is not known to be missing: Failed, so the row does not say "None yet".
45+ */
46+async function policyPath(
47+ path: { namespace: string; name: string },
48+ viewer: ReturnType<typeof getViewer>,
49+ defaultBranch: string,
50+): Promise<string | null | Failed> {
51+ const found = await Promise.all(POLICY_PATHS.map((file) => part(repos.blob(path, viewer, defaultBranch, file), "The security policy")));
52+ const at = POLICY_PATHS.find((_, index) => {
53+ const one = found[index];
54+ return !isFailed(one) && one.ok;
55+ });
56+ return at ?? found.find(isFailed) ?? null;
4357 }
4458
4559 export async function loader({ params, context, request }: Route.LoaderArgs) {
5266 const path = { namespace: params.owner, name: params.repo };
5367 if (!access.insider || !access.can.security_alerts) {
5468 // The public face: for anyone who can see the repository.
69+ // Each is a row or a section of its own, which says so when it could not be read.
5570 const [policy, published, reporting] = await Promise.all([
5671 policyPath(path, viewer, repo.defaultBranch),
57− advisories.list(path, viewer, "published"),
58− advisories.reportSettings(path, viewer),
72+ part(advisories.list(path, viewer, "published"), "Published advisories"),
73+ part(advisories.reportSettings(path, viewer), "Private vulnerability reporting"),
5974 ]);
6075 return {
6176 kind: "public" as const,
6277 policy,
63− advisories: published.ok ? published.value : [],
64− reporting: reporting.ok && reporting.value.privateVulnerabilityReporting,
78+ advisories: isFailed(published) ? published : published.ok ? published.value : [],
79+ reporting: isFailed(reporting) ? reporting : reporting.ok && reporting.value.privateVulnerabilityReporting,
6580 signedIn: Boolean(viewer),
6681 };
6782 }
83+ // The alerts and the settings are what the overview is about; code
84+ // scanning, the policy, the advisories and the reports are a row or a
85+ // count each, which says so in its place when it could not be read.
6886 const [overview, code, view, policy, published, reports] = await Promise.all([
6987 security.overview(path, viewer),
70− securitySuite.codeScanning(path, viewer),
88+ part(securitySuite.codeScanning(path, viewer), "Code scanning"),
7189 securitySuite.settings(path, viewer),
7290 policyPath(path, viewer, repo.defaultBranch),
73− advisories.list(path, viewer, "published"),
74− advisories.reports(path, viewer, "new"),
91+ part(advisories.list(path, viewer, "published"), "Security advisories"),
92+ part(advisories.reports(path, viewer, "new"), "New vulnerability reports"),
7593 ]);
7694 const settings = unwrap(view);
95+ const scanning = isFailed(code) ? null : code;
96+ // Rows whose facts could not be read, by key, and what they are.
97+ const unavailable: Record<string, string> = {};
98+ if (isFailed(code)) unavailable.code_scanning = code.what;
99+ if (isFailed(policy)) unavailable.policy = policy.what;
100+ if (isFailed(published)) unavailable.advisories = published.what;
77101 return {
78102 kind: "insider" as const,
79103 overview: unwrap(overview),
80− code: code.ok ? code.value : null,
104+ code: scanning?.ok ? scanning.value : null,
105+ codeFailed: isFailed(code),
81106 rows: overviewRows({
82107 base,
83− policyPath: policy,
108+ policyPath: isFailed(policy) ? null : policy,
84109 reporting: settings.settings.privateVulnerabilityReporting,
85− advisories: published.ok ? published.value.length : 0,
110+ advisories: !isFailed(published) && published.ok ? published.value.length : 0,
86111 upkeep: settings.upkeep,
87− codeScanning: { configured: code.ok ? code.value.configured : false, analyses: code.ok ? code.value.analyses.length : 0, entitled: settings.entitled },
112+ codeScanning: {
113+ configured: scanning?.ok ? scanning.value.configured : false,
114+ analyses: scanning?.ok ? scanning.value.analyses.length : 0,
115+ entitled: settings.entitled,
116+ },
88117 historyAlerts: settings.settings.historyAlerts,
89118 canManage: Boolean(access.can.manage_settings),
90119 canPush: Boolean(access.can.push),
91120 }),
92− newReports: reports.ok ? reports.value.length : 0,
121+ unavailable,
122+ newReports: isFailed(reports) ? reports : reports.ok ? reports.value.length : 0,
93123 can: access.can,
94124 };
95125 }
157187 );
158188 }
159189
160−function FeatureRows({ title, rows, action, canManage }: { title: string; rows: OverviewRow[]; action: string; canManage: boolean }) {
190+function FeatureRows({
191+ title,
192+ rows,
193+ action,
194+ canManage,
195+ unavailable,
196+}: {
197+ title: string;
198+ rows: OverviewRow[];
199+ action: string;
200+ canManage: boolean;
201+ /** Rows whose facts could not be read: each says so in place of its status. */
202+ unavailable: Record<string, string>;
203+}) {
161204 return (
162205 <section aria-labelledby={`rows-${title}`} className="space-y-2">
163206 <h3 id={`rows-${title}`} className="text-sm font-semibold">
165208 </h3>
166209 <Card asChild divided>
167210 <ul>
168− {rows.map((row) => (
169− <li key={row.key} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
170− <div className="min-w-0 grow">
171− <div className="flex flex-wrap items-center gap-2">
172− <span className="text-sm font-medium">{row.title}</span>
173− <Hint label={row.hint}>
174− <Badge tone={STATUS_TONE[row.status]}>{STATUS_LABEL[row.status]}</Badge>
175− </Hint>
211+ {rows.map((row) =>
212+ unavailable[row.key] ? (
213+ <UnavailableRow key={row.key} title={row.title} what={unavailable[row.key]} />
214+ ) : (
215+ <li key={row.key} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
216+ <div className="min-w-0 grow">
217+ <div className="flex flex-wrap items-center gap-2">
218+ <span className="text-sm font-medium">{row.title}</span>
219+ <Hint label={row.hint}>
220+ <Badge tone={STATUS_TONE[row.status]}>{STATUS_LABEL[row.status]}</Badge>
221+ </Hint>
222+ </div>
223+ <p className="mt-1 text-sm text-muted">{row.about}</p>
176224 </div>
177− <p className="mt-1 text-sm text-muted">{row.about}</p>
178− </div>
179− <div className="shrink-0 sm:text-right">
180− <RowAction row={row} action={action} canManage={canManage} />
181− </div>
182− </li>
183− ))}
225+ <div className="shrink-0 sm:text-right">
226+ <RowAction row={row} action={action} canManage={canManage} />
227+ </div>
228+ </li>
229+ ),
230+ )}
184231 </ul>
185232 </Card>
186233 </section>
187234 );
188235 }
189236
237+/** A row whose status could not be read: its title, and that, in place of a status that would be a guess. */
238+function UnavailableRow({ title, what }: { title: string; what: string }) {
239+ return (
240+ <li className="space-y-1 p-4">
241+ <span className="text-sm font-medium">{title}</span>
242+ <Unavailable what={what} compact />
243+ </li>
244+ );
245+}
246+
190247 function AdvisoryLine({ advisory, base }: { advisory: Advisory; base: string }) {
191248 return (
192249 <li className="flex items-center gap-3 px-4 py-3">
216273 title="Security"
217274 about="How to report a vulnerability in this repository, and what its maintainers have published about vulnerabilities in what it ships."
218275 actions={
219− reporting ? (
276+ reporting === true ? (
220277 <ButtonLink to={`${base}/security/report`} prefetch="intent">
221278 Report a vulnerability
222279 </ButtonLink>
225282 />
226283 <Card asChild divided>
227284 <ul>
228− <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
229− <div className="min-w-0 grow">
230− <div className="flex flex-wrap items-center gap-2">
231− <span className="text-sm font-medium">Security policy</span>
232− <Badge tone={policy ? "success" : "neutral"}>{policy ? "Published" : "None yet"}</Badge>
285+ {isFailed(policy) ? (
286+ <UnavailableRow title="Security policy" what={policy.what} />
287+ ) : (
288+ <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
289+ <div className="min-w-0 grow">
290+ <div className="flex flex-wrap items-center gap-2">
291+ <span className="text-sm font-medium">Security policy</span>
292+ <Badge tone={policy ? "success" : "neutral"}>{policy ? "Published" : "None yet"}</Badge>
293+ </div>
294+ <p className="mt-1 text-sm text-muted">
295+ {policy ? `${policy} says how to report a vulnerability and which versions are supported.` : "This repository has not published a security policy."}
296+ </p>
233297 </div>
234− <p className="mt-1 text-sm text-muted">
235− {policy ? `${policy} says how to report a vulnerability and which versions are supported.` : "This repository has not published a security policy."}
236− </p>
237− </div>
238− {policy && (
239− <ButtonLink to={`${base}/blob/HEAD/${policy}`} variant="outline" size="sm">
240− View policy
241− </ButtonLink>
242− )}
243− </li>
244− <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
245− <div className="min-w-0 grow">
246− <div className="flex flex-wrap items-center gap-2">
247− <span className="text-sm font-medium">Private vulnerability reporting</span>
248− <Badge tone={reporting ? "success" : "neutral"}>{reporting ? "Enabled" : "Disabled"}</Badge>
298+ {policy && (
299+ <ButtonLink to={`${base}/blob/HEAD/${policy}`} variant="outline" size="sm">
300+ View policy
301+ </ButtonLink>
302+ )}
303+ </li>
304+ )}
305+ {isFailed(reporting) ? (
306+ <UnavailableRow title="Private vulnerability reporting" what={reporting.what} />
307+ ) : (
308+ <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
309+ <div className="min-w-0 grow">
310+ <div className="flex flex-wrap items-center gap-2">
311+ <span className="text-sm font-medium">Private vulnerability reporting</span>
312+ <Badge tone={reporting ? "success" : "neutral"}>{reporting ? "Enabled" : "Disabled"}</Badge>
313+ </div>
314+ <p className="mt-1 text-sm text-muted">
315+ {reporting
316+ ? signedIn
317+ ? "Tell the maintainers privately, under your name. Only they see the report."
318+ : "Tell the maintainers privately. You can report without an account; leave an email so they can answer you."
319+ : "This repository does not take private reports here. Its security policy, if any, says how to reach the maintainers."}
320+ </p>
249321 </div>
250− <p className="mt-1 text-sm text-muted">
251− {reporting
252− ? signedIn
253− ? "Tell the maintainers privately, under your name. Only they see the report."
254− : "Tell the maintainers privately. You can report without an account; leave an email so they can answer you."
255− : "This repository does not take private reports here. Its security policy, if any, says how to reach the maintainers."}
256− </p>
257− </div>
258− </li>
322+ </li>
323+ )}
259324 </ul>
260325 </Card>
261326 <section aria-labelledby="public-advisories" className="space-y-2">
263328 <h3 id="public-advisories" className="text-sm font-semibold">
264329 Security advisories
265330 </h3>
266− {published.length > 0 && (
331+ {!isFailed(published) && published.length > 0 && (
267332 <Link to={`${base}/security/advisories`} className="text-sm text-muted underline underline-offset-2 hover:text-fg">
268333 All advisories
269334 </Link>
270335 )}
271336 </div>
272− {published.length === 0 ? (
337+ {isFailed(published) ? (
338+ <Unavailable what={published.what} />
339+ ) : published.length === 0 ? (
273340 <EmptyState title="No published advisories">The maintainers have not published any security advisories for this repository.</EmptyState>
274341 ) : (
275342 <Card asChild divided>
284351 </div>
285352 );
286353 }
287− const { overview, code, rows, newReports, can } = loaderData;
354+ const { overview, code, codeFailed, rows, unavailable, newReports, can } = loaderData;
288355 const codeOpen = code ? severityCounts(code.alerts) : null;
289356 // Open alerts of every kind, by severity.
290357 const counts = { ...overview.counts };
297364 title="Security"
298365 about="What keeps this repository safe, and where each part stands. Pushes that add a secret are refused before they land, every package the lockfiles resolve is checked for known vulnerabilities, code scanning reads your tools' results, and the maintainers publish advisories and take private reports."
299366 actions={
300− newReports > 0 ? (
367+ isFailed(newReports) ? (
368+ <Unavailable what={newReports.what} compact />
369+ ) : newReports > 0 ? (
301370 <ButtonLink to={`${base}/security/reports`} variant="outline" size="sm" prefetch="intent">
302371 {newReports === 1 ? "1 new vulnerability report" : `${newReports} new vulnerability reports`}
303372 </ButtonLink>
304373 ) : null
305374 }
306375 />
307− <FeatureRows title="Policy" rows={rowsIn(rows, "policy")} action={action} canManage={canManage} />
308− <FeatureRows title="Findings" rows={rowsIn(rows, "findings")} action={action} canManage={canManage} />
309− <FeatureRows title="Reporting" rows={rowsIn(rows, "reporting")} action={action} canManage={canManage} />
376+ <FeatureRows title="Policy" rows={rowsIn(rows, "policy")} action={action} canManage={canManage} unavailable={unavailable} />
377+ <FeatureRows title="Findings" rows={rowsIn(rows, "findings")} action={action} canManage={canManage} unavailable={unavailable} />
378+ <FeatureRows title="Reporting" rows={rowsIn(rows, "reporting")} action={action} canManage={canManage} unavailable={unavailable} />
310379 <section aria-labelledby="open-alerts" className="space-y-3">
311380 <h3 id="open-alerts" className="flex items-center gap-2 text-sm font-semibold">
312381 <ShieldCheck size={15} className="text-accent" />
313382 Open alerts
314383 </h3>
315384 <SeverityCountsGrid counts={counts} />
385+ {/* Without code scanning's alerts, the counts above are of the rest. */}
386+ {codeFailed && <Unavailable what="Code scanning alerts" compact />}
316387 <p className="text-xs text-faint">
317388 Open alerts of every kind by severity ({SEVERITY_LABEL.critical.toLowerCase()} to {SEVERITY_LABEL.low.toLowerCase()}). A secret in the history
318389 that looks real counts as critical; blocked pushes and likely test values do not.
+12−4
2222 import { advisories } from "../../lib/services.server";
2323 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
2424 import { refusal, requireInsider } from "../../lib/access.server";
25+import { isFailed, part } from "../../lib/partial";
2526 import { whyNot } from "../../lib/access";
2627 import { REPORT_STATES, REPORT_STATE_LABEL, REPORT_STATE_TONE, parseReportState } from "../../lib/advisories";
2728
3334 const viewer = getViewer(context) ?? requireUser(context, request);
3435 const { access } = await requireInsider(context, params, "security_alerts");
3536 const path = { namespace: params.owner, name: params.repo };
36− const [reports, settings] = await Promise.all([advisories.reports(path, viewer), advisories.reportSettings(path, viewer)]);
37− return { reports: unwrap(reports), reporting: settings.ok && settings.value.privateVulnerabilityReporting, can: access.can };
37+ // The reports are what the page is about. Whether reporting is on only
38+ // changes a line: unknown (null) when it cannot be read, so the page
39+ // does not say it is off.
40+ const [reports, settings] = await Promise.all([advisories.reports(path, viewer), part(advisories.reportSettings(path, viewer), "Private vulnerability reporting")]);
41+ return {
42+ reports: unwrap(reports),
43+ reporting: isFailed(settings) ? null : settings.ok && settings.value.privateVulnerabilityReporting,
44+ can: access.can,
45+ };
3846 }
3947
4048 export async function action({ params, context, request }: Route.ActionArgs) {
111119 <SectionHeader
112120 title="Vulnerability reports"
113121 about={
114− reporting
122+ reporting !== false
115123 ? "What people told this repository's maintainers privately. Accept a report to draft an advisory from it, in the reporter's words and crediting them; decline what is not a vulnerability. Reporters never see this list."
116124 : "Private vulnerability reporting is off for this repository: nobody can send a new report. Turn it on from the Security overview. Reports already sent are here."
117125 }
136144 </div>
137145 {shown.length === 0 ? (
138146 <EmptyState title={state ? `No ${REPORT_STATE_LABEL[state].toLowerCase()} reports` : "No reports"}>
139− {reporting ? "Nobody has reported a vulnerability privately yet." : "Nothing was reported while private reporting was on."}
147+ {reporting !== false ? "Nobody has reported a vulnerability privately yet." : "Nothing was reported while private reporting was on."}
140148 </EmptyState>
141149 ) : (
142150 <div className="space-y-3">
+14−3
1313 import { security, securitySuite } from "../../lib/services.server";
1414 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
1515 import { refusal, requireInsider } 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: `Secret alert · ${params.owner}/${params.repo} · g1t` });
2325 const viewer = getViewer(context) ?? requireUser(context, request);
2426 const { access } = await requireInsider(context, params, "security_alerts");
2527 const repo = { namespace: params.owner, name: params.repo };
26− const [detail, settings] = await Promise.all([securitySuite.secretAlert(repo, params.id, viewer), securitySuite.settings(repo, viewer)]);
27− return { detail: unwrap(detail), settings: settings.ok ? settings.value : null, can: access.can };
28+ // The settings only say whether its validity can be asked: when they
29+ // cannot be read, that says so, rather than that checks are off.
30+ const [detail, settings] = await Promise.all([
31+ securitySuite.secretAlert(repo, params.id, viewer),
32+ part(securitySuite.settings(repo, viewer), "Whether validity checks are on"),
33+ ]);
34+ return { detail: unwrap(detail), settings: isFailed(settings) ? settings : settings.ok ? settings.value : null, can: access.can };
2835 }
2936
3037 const REASONS = new Set(["false_positive", "used_in_tests", "revoked", "wont_fix"]);
159166 </p>
160167 <p className="mt-1 text-xs text-muted">{validity.about}</p>
161168 <div className="mt-2">
162− <ValidityCheck action={action} checkable={detail.checkable && landed} enabled={Boolean(settings?.workspace.validityChecks && settings.entitled)} />
169+ {isFailed(settings) ? (
170+ <Unavailable what={settings.what} compact />
171+ ) : (
172+ <ValidityCheck action={action} checkable={detail.checkable && landed} enabled={Boolean(settings?.workspace.validityChecks && settings.entitled)} />
173+ )}
163174 </div>
164175 </div>
165176 <div>
+14−6
1212 import { security, securitySuite } from "../../lib/services.server";
1313 import { assertSameOrigin, getViewer, managesSecurity, requireUser, unwrap } from "../../lib/session.server";
1414 import { refusal, requireInsider } from "../../lib/access.server";
15+import { Unavailable } from "../../components/boundary";
16+import { isFailed, part } from "../../lib/partial";
1517 import { planPrice } from "../../lib/security-suite.server";
1618 import { countStates, keepSecret, secretFilters, secretTypes } from "../../lib/security-suite";
1719 import { repoPages } from "../../lib/repo-live";
2527 const viewer = getViewer(context) ?? requireUser(context, request);
2628 const { access } = await requireInsider(context, params, "push");
2729 const repo = { namespace: params.owner, name: params.repo };
30+ // The alerts are what the page is about. Custom patterns are a section
31+ // of their own: when they cannot be read it says so, rather than offer
32+ // to turn on what may well be on.
2833 const [overview, patterns, price] = await Promise.all([
2934 security.overview(repo, viewer),
30− securitySuite.patterns(params.owner, repo, viewer),
35+ part(securitySuite.patterns(params.owner, repo, viewer), "Custom patterns"),
3136 planPrice(params.owner, viewer),
3237 ]);
3338 return {
3439 overview: unwrap(overview),
35− patterns: patterns.ok ? patterns.value : { patterns: [], entitled: false },
40+ patterns: isFailed(patterns) ? patterns : patterns.ok ? patterns.value : { patterns: [], entitled: false },
3641 price,
3742 can: access.can,
3843 owner: managesSecurity(viewer, params.owner),
7984 else next.delete(key);
8085 setSearch(next, { replace: true, preventScrollReset: true });
8186 };
82− const own = patterns.patterns.filter((pattern) => pattern.scope === "repository");
83− const inherited = patterns.patterns.filter((pattern) => pattern.scope === "workspace");
87+ const listed = isFailed(patterns) ? [] : patterns.patterns;
88+ const own = listed.filter((pattern) => pattern.scope === "repository");
89+ const inherited = listed.filter((pattern) => pattern.scope === "workspace");
8490 return (
8591 <div className="max-w-5xl space-y-8">
8692 <SectionHeader
130136 </h3>
131137 <p className="mt-1 text-sm text-muted">Formats of your own, found by push protection and history scans with the built-in ones.</p>
132138 </div>
133− {patterns.entitled && can.manage_integrations && (
139+ {!isFailed(patterns) && patterns.entitled && can.manage_integrations && (
134140 <Link to={`${base}/security/secret-scanning/patterns`} className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm hover:border-line-strong">
135141 <Plus size={14} /> New pattern
136142 </Link>
137143 )}
138144 </div>
139− {!patterns.entitled ? (
145+ {isFailed(patterns) ? (
146+ <Unavailable what={patterns.what} />
147+ ) : !patterns.entitled ? (
140148 <ActivationPrompt workspace={params.owner} feature="Custom patterns" monthlyCents={price} isOwner={owner} />
141149 ) : own.length + inherited.length === 0 ? (
142150 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-sm text-muted">
+8−5
3939 import { billing, identity } from "../../lib/services.server";
4040 import { assertSameOrigin, requireUser, roleIn, unwrap } from "../../lib/session.server";
4141 import { UserCard } from "../../components/user-card";
42+import { Unavailable } from "../../components/boundary";
43+import { isFailed, part } from "../../lib/partial";
4244
4345 export function meta({ params, ...args }: Route.MetaArgs) {
4446 return page(args, { title: `Access · ${params.owner}/${params.repo} · g1t` });
5052 const { viewer, access } = await requireInsider(context, params, "push");
5153 const manage = access.can.manage_access;
5254 // The workspace's teams, for Add team: only Admins add one, and a
53− // failure there leaves the rest of the page as it is.
55+ // failure there says so in Add team's place, not as "no other team".
5456 const [found, teams, free] = await Promise.all([
5557 identity.repoAccess(params.owner, params.repo, viewer),
56− manage ? identity.listTeams(viewer, params.owner).catch(() => null) : Promise.resolve(null),
58+ manage ? part(identity.listTeams(viewer, params.owner), "Teams to add") : Promise.resolve(null),
5759 // A free workspace invites no one from outside; identity refuses it
5860 // either way, so a failure here only hides the note.
5961 manage ? billing.freeWorkspaces([params.owner]).catch(() => [] as string[]) : Promise.resolve([] as string[]),
6062 ]);
6163 return {
6264 access: unwrap(found),
63− teams: teams?.ok ? teams.value : ([] as Team[]),
65+ teams: isFailed(teams) ? teams : teams?.ok ? teams.value : ([] as Team[]),
6466 manage,
6567 free: free.includes(params.owner.toLowerCase()),
6668 // Owners change the base permission, on the workspace's People page.
165167 (a, b) => REPO_ROLES.indexOf(b.role) - REPO_ROLES.indexOf(a.role) || a.name.localeCompare(b.name),
166168 );
167169 const listed = new Set(repoTeams.map((team) => team.slug));
168− const addable = teams.filter((team) => !listed.has(team.slug));
170+ const addable = isFailed(teams) ? null : teams.filter((team) => !listed.has(team.slug));
169171 // Roles are changed and people removed from their own rows, which say
170172 // what went wrong; what went right is said once, under the list.
171173 const [notice, setNotice] = useState<string | null>(null);
262264 </ul>
263265 </Card>
264266 )}
265− {manage && <AddTeamForm teams={addable} result={teamResult} workspace={params.owner} />}
267+ {manage && addable && <AddTeamForm teams={addable} result={teamResult} workspace={params.owner} />}
268+ {manage && isFailed(teams) && <Unavailable what={teams.what} compact />}
266269 </Section>
267270
268271 {manage && (
+21−12
1919 import { actions, repos, work } from "../../lib/services.server";
2020 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
2121 import { requireCapability, requireInsider } from "../../lib/access.server";
22+import { Unavailable } from "../../components/boundary";
23+import { isFailed, part } from "../../lib/partial";
2224
2325 const COMMIT_MESSAGE_DEFAULTS: CommitMessageDefault[] = [
2426 "default",
4042 const [repo, settings, seen, workflows] = await Promise.all([
4143 repos.get(path, viewer),
4244 work.getSettings(path, viewer),
43− work.seenChecks(path, viewer),
44− actions.workflows(path, viewer),
45+ // Only for offering to add CI: when either cannot be read, it is not offered.
46+ part(work.seenChecks(path, viewer), "Checks seen"),
47+ part(actions.workflows(path, viewer), "Workflows"),
4548 ]);
4649 const found = unwrap(repo);
47− // What holds for the default branch: shown here, changed in Rules.
48− const effective = await work.effectiveRules(path, found.defaultBranch, viewer).catch(() => null);
50+ // What holds for the default branch: shown here, changed in Rules. A
51+ // failure says so in its place: read as no rules, it would say the
52+ // branch is unprotected.
53+ const effective = await part(work.effectiveRules(path, found.defaultBranch, viewer), `The rules for ${found.defaultBranch}`);
4954 // Streamed: the CODEOWNERS file is read and checked on its own time.
5055 const codeowners = work
5156 .codeownersErrors(path, viewer)
5560 codeowners,
5661 repo: found,
5762 settings: unwrap(settings),
58− effective: effective?.ok ? effective.value : null,
63+ effective: isFailed(effective) ? effective : effective.ok ? effective.value : null,
5964 // Nothing to require until something runs: the page offers to add CI.
60− noChecks: workflows.ok && workflows.value.length === 0 && seen.ok && seen.value.length === 0,
65+ noChecks: !isFailed(workflows) && workflows.ok && workflows.value.length === 0 && !isFailed(seen) && seen.ok && seen.value.length === 0,
6166 canPush: access.can.push,
6267 };
6368 }
111116 const branch = repo.defaultBranch;
112117 const base = `/${repo.namespace}/${repo.name}`;
113118 const archived = Boolean(repo.archivedAt);
114− const active = effective?.rules.filter((rule) => rule.enforcement === "active") ?? [];
119+ const rules = isFailed(effective) ? null : effective;
120+ const active = rules?.rules.filter((rule) => rule.enforcement === "active") ?? [];
115121 const labels = [...new Set(active.map((rule) => ruleInfo(rule.type)?.label ?? rule.type))];
116122 return (
117123 <>
134140 <Scale size={16} className="mt-0.5 shrink-0 text-accent" />
135141 <span className="min-w-0 grow">
136142 <span className="block text-sm font-medium">Rules for {branch}</span>
137− <span className="mt-1 block text-sm text-muted">
138− {labels.length > 0 ? labels.join(" · ") : `No active rules hold for ${branch}: anyone who may push can change it.`}
139− </span>
140− {effective && effective.rulesets.length > 0 && (
143+ {!isFailed(effective) && (
144+ <span className="mt-1 block text-sm text-muted">
145+ {labels.length > 0 ? labels.join(" · ") : `No active rules hold for ${branch}: anyone who may push can change it.`}
146+ </span>
147+ )}
148+ {rules && rules.rulesets.length > 0 && (
141149 <span className="mt-2 flex flex-wrap gap-2">
142− {effective.rulesets.map((ruleset) => (
150+ {rules.rulesets.map((ruleset) => (
143151 <span key={ruleset.id} className="inline-flex items-center gap-1.5 text-xs text-muted">
144152 {ruleset.name} <EnforcementBadge enforcement={ruleset.enforcement} />
145153 </span>
149157 </span>
150158 <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
151159 </Link>
160+ {isFailed(effective) && <Unavailable what={effective.what} compact />}
152161 <Link
153162 to={`${base}/settings/guardrails`}
154163 className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
+83−50
2626 requireUser,
2727 } from "../../lib/session.server";
2828 import { refusal, requireInsider } from "../../lib/access.server";
29+import { Unavailable } from "../../components/boundary";
30+import { isFailed, part } from "../../lib/partial";
2931
3032 export function meta({ params, ...args }: Route.MetaArgs) {
3133 return page(args, { title: `Repository settings · ${params.owner}/${params.repo} · g1t` });
3638 // Maintain and up; to anyone without a role here the page does not exist.
3739 const { repo, access } = await requireInsider(context, params, "manage_settings");
3840 const path = { namespace: params.owner, name: params.repo };
41+ // Each of these is one section's: when one cannot be read, that section
42+ // says so and has no form to save over what it could not show.
3943 const [branches, retention, settings] = await Promise.all([
40− repos.branches(path, viewer),
41− actions.artifactRetention(path, viewer),
42− work.getSettings(path, viewer).catch(() => null),
44+ part(repos.branches(path, viewer), "Its branches"),
45+ part(actions.artifactRetention(path, viewer), "How long artifacts are kept"),
46+ part(work.getSettings(path, viewer), "These settings"),
4347 ]);
4448 // Renaming and the default branch are for Admins; visibility, for owners
4549 // of the workspace unless its member privileges let its members with
4852 return {
4953 repo,
5054 // An empty repository has no branches yet; the page still works.
51− branches: branches.ok ? branches.value.map((b) => b.name) : [],
55+ branches: isFailed(branches) ? branches : branches.ok ? branches.value.map((b) => b.name) : [],
5256 owner,
5357 visibility: access.can.change_visibility,
5458 // How long workflow runs' artifacts are kept.
55− retention: retention.ok ? retention.value : { days: 14, maximum_allowed_days: 90 },
56− // Whether issues can be opened in it; null when that could not be read.
57− issues: settings?.ok ? { on: settings.value.issuesEnabled ?? true, chosen: settings.value.hasIssues != null } : null,
58− // How commits and issues are handled; null when that could not be read.
59− pullSettings: settings?.ok
60− ? {
61− autoCloseIssues: settings.value.autoCloseIssues,
62− requireWebCommitSignOff: settings.value.requireWebCommitSignOff,
63− allowCommitComments: settings.value.allowCommitComments,
64− }
65− : null,
59+ retention: isFailed(retention) ? retention : retention.ok ? retention.value : { days: 14, maximum_allowed_days: 90 },
60+ // Whether issues can be opened in it; null when it was not answered.
61+ issues: isFailed(settings)
62+ ? settings
63+ : settings.ok
64+ ? { on: settings.value.issuesEnabled ?? true, chosen: settings.value.hasIssues != null }
65+ : null,
66+ // How commits and issues are handled; null when it was not answered.
67+ pullSettings: isFailed(settings)
68+ ? settings
69+ : settings.ok
70+ ? {
71+ autoCloseIssues: settings.value.autoCloseIssues,
72+ requireWebCommitSignOff: settings.value.requireWebCommitSignOff,
73+ allowCommitComments: settings.value.allowCommitComments,
74+ }
75+ : null,
6676 };
6777 }
6878
167177 }
168178
169179 export default function RepoSettings({ loaderData, actionData }: Route.ComponentProps) {
170− const { repo, branches, owner, visibility, retention, issues, pullSettings } = loaderData;
180+ const { repo, branches, owner, visibility, retention, issues: features, pullSettings: commits } = loaderData;
181+ // Settings that could not be read are said to be, in their sections.
182+ const issues = isFailed(features) ? null : features;
183+ const pullSettings = isFailed(commits) ? null : commits;
171184 const navigation = useNavigation();
172185 const posting = (intent: string) => navigation.state !== "idle" && navigation.formData?.get("intent") === intent;
173186 const base = `/${repo.namespace}/${repo.name}`;
221234 title="Branches"
222235 about="The branch everything lands on, and renaming branches."
223236 >
224− <DefaultBranchForm repo={repo} branches={branches} archived={archived || !owner} busy={posting("default-branch")} result={actionData} />
225− {/* Keyed to the branches, so after a rename it starts over on the branches there are now. */}
226− <RenameBranchForm
227− key={branches.join(" ")}
228− repo={repo}
229− branches={branches}
230− owner={owner}
231− archived={archived}
232− busy={posting("rename-branch")}
233− result={actionData}
234− />
237+ {isFailed(branches) ? (
238+ <Unavailable what={branches.what} />
239+ ) : (
240+ <>
241+ <DefaultBranchForm repo={repo} branches={branches} archived={archived || !owner} busy={posting("default-branch")} result={actionData} />
242+ {/* Keyed to the branches, so after a rename it starts over on the branches there are now. */}
243+ <RenameBranchForm
244+ key={branches.join(" ")}
245+ repo={repo}
246+ branches={branches}
247+ owner={owner}
248+ archived={archived}
249+ busy={posting("rename-branch")}
250+ result={actionData}
251+ />
252+ </>
253+ )}
235254 <Link
236255 to={`${base}/settings/branches`}
237256 className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
248267 </Link>
249268 </Section>
250269
270+ {isFailed(features) && (
271+ <Section title="Features" about="What the repository has besides its code.">
272+ <Unavailable what="Its features" />
273+ </Section>
274+ )}
251275 {issues && (
252276 <Section title="Features" about="What the repository has besides its code.">
253277 <Form method="post" key={`${issues.on}|${pullSettings?.autoCloseIssues}`} className="space-y-3">
282306 </Section>
283307 )}
284308
309+ {isFailed(commits) && (
310+ <Section id="commits" title="Commits" about="Commits made on g1t, and talking about commits.">
311+ <Unavailable what="Its commit settings" />
312+ </Section>
313+ )}
285314 {pullSettings && (
286315 <Section id="commits" title="Commits" about="Commits made on g1t, and talking about commits.">
287316 <Form method="post" key={`${pullSettings.requireWebCommitSignOff}|${pullSettings.allowCommitComments}`} className="space-y-3">
347376 </Section>
348377
349378 <Section title="Artifacts" about="How long the files workflow runs upload are kept.">
350− <Form method="post" key={retention.days} className="space-y-3">
351− <input type="hidden" name="intent" value="artifacts" />
352− <Field
353− label="Days to keep artifacts"
354− hint={`1 to ${retention.maximum_allowed_days}. A workflow's retention-days can ask for fewer, never more. Artifacts already uploaded keep their expiry.`}
355− >
356− <Input
357− name="days"
358− type="number"
359− min={1}
360− max={retention.maximum_allowed_days}
361− required
362− defaultValue={retention.days}
363− className="w-28"
364− />
365− </Field>
366− <div className="flex flex-wrap items-center gap-4 pt-1">
367− <SubmitButton match={{ intent: "artifacts" }} pending="Saving…">
368− Save
369− </SubmitButton>
370− <Status intent="artifacts" data={actionData} />
371− </div>
372− </Form>
379+ {isFailed(retention) ? (
380+ <Unavailable what={retention.what} />
381+ ) : (
382+ <Form method="post" key={retention.days} className="space-y-3">
383+ <input type="hidden" name="intent" value="artifacts" />
384+ <Field
385+ label="Days to keep artifacts"
386+ hint={`1 to ${retention.maximum_allowed_days}. A workflow's retention-days can ask for fewer, never more. Artifacts already uploaded keep their expiry.`}
387+ >
388+ <Input
389+ name="days"
390+ type="number"
391+ min={1}
392+ max={retention.maximum_allowed_days}
393+ required
394+ defaultValue={retention.days}
395+ className="w-28"
396+ />
397+ </Field>
398+ <div className="flex flex-wrap items-center gap-4 pt-1">
399+ <SubmitButton match={{ intent: "artifacts" }} pending="Saving…">
400+ Save
401+ </SubmitButton>
402+ <Status intent="artifacts" data={actionData} />
403+ </div>
404+ </Form>
405+ )}
373406 </Section>
374407
375408 {repo.isPrivate && (
+19−8
1212 import { getViewer, unwrap } from "../../lib/session.server";
1313 import { requireInsider } from "../../lib/access.server";
1414 import { movedTab, rulesAt, rulesPath } from "../../lib/tab-paths";
15+import { Unavailable } from "../../components/boundary";
16+import { isFailed, part } from "../../lib/partial";
1517
1618 export function meta({ params, ...args }: Route.MetaArgs) {
1719 return page(args, { title: `Rules · ${params.owner}/${params.repo} · g1t` });
2931 const path = { namespace: params.owner, name: params.repo };
3032 const tagged = url.searchParams.get("tag");
3133 const branch = (tagged ?? url.searchParams.get("branch") ?? repo.defaultBranch).trim() || repo.defaultBranch;
34+ // What holds for a branch, and Insights, each say in their place when
35+ // they could not be read; the rulesets are what the page is about.
3236 const [rulesets, effective, evaluations] = await Promise.all([
3337 work.listRulesets({ repo: path }, viewer, true),
34− tab === "rulesets" ? work.effectiveRules(path, branch, viewer, tagged ? "tag" : "branch") : Promise.resolve(null),
38+ tab === "rulesets" ? part(work.effectiveRules(path, branch, viewer, tagged ? "tag" : "branch"), `The rules for ${branch}`) : Promise.resolve(null),
3539 tab === "insights"
36− ? work.ruleEvaluations({ repo: path }, viewer, {
37− before: url.searchParams.get("before") ?? undefined,
38− problems_only: url.searchParams.get("problems") === "1",
39− })
40+ ? part(
41+ work.ruleEvaluations({ repo: path }, viewer, {
42+ before: url.searchParams.get("before") ?? undefined,
43+ problems_only: url.searchParams.get("problems") === "1",
44+ }),
45+ "Insights",
46+ )
4047 : Promise.resolve(null),
4148 ]);
4249 return {
4552 target: tagged ? ("tag" as const) : ("branch" as const),
4653 defaultBranch: repo.defaultBranch,
4754 rulesets: unwrap(rulesets),
48− effective: effective?.ok ? effective.value : null,
49− evaluations: evaluations?.ok ? evaluations.value : null,
55+ effective: isFailed(effective) ? effective : effective?.ok ? effective.value : null,
56+ evaluations: isFailed(evaluations) ? evaluations : evaluations?.ok ? evaluations.value : null,
5057 editable: access.can.manage_protection && !repo.archivedAt,
5158 };
5259 }
134141 Show rules
135142 </Button>
136143 </Form>
137− {effective ? (
144+ {isFailed(effective) ? (
145+ <Unavailable what={effective.what} />
146+ ) : effective ? (
138147 <EffectiveRulesView effective={effective} hrefFor={hrefFor} />
139148 ) : (
140149 <p className="text-sm text-muted">The rules for that name could not be read.</p>
141150 )}
142151 </section>
143152 </div>
153+ ) : isFailed(evaluations) ? (
154+ <Unavailable what={evaluations.what} />
144155 ) : evaluations ? (
145156 <InsightsView page={evaluations} olderHref={evaluations.next ? `${base}/settings/rules/insights?before=${encodeURIComponent(evaluations.next)}` : null} />
146157 ) : (
+4−1
1111 import { work } from "../../lib/services.server";
1212 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
1313 import { requireCapability, requireInsider } from "../../lib/access.server";
14+import { orElse, part } from "../../lib/partial";
1415
1516 export function meta({ params, loaderData, ...args }: Route.MetaArgs) {
1617 const name = loaderData?.existing?.name ?? "New ruleset";
2122 const viewer = getViewer(context);
2223 const { repo, access } = await requireInsider(context, params, "manage_protection");
2324 const path = { namespace: params.owner, name: params.repo };
24− const seen = await work.seenChecks(path, viewer).catch(() => null);
25+ // Only suggestions for required checks, deep in a rule; any name can be
26+ // typed without them, so the form quietly does without.
27+ const seen = orElse(await part(work.seenChecks(path, viewer), "Checks seen"), null);
2528 const base = {
2629 seen: seen?.ok ? seen.value : [],
2730 editable: access.can.manage_protection && !repo.archivedAt,

Comments

Sign in to comment.