Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 127 additions & 83 deletions src/app/orgs/[slug]/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,11 +354,27 @@ export default async function OrgSettingsPage({
{configArtifactDescription(artifact)}
</p>
</div>
<span
className={`h-fit rounded-full border px-2.5 py-0.5 font-mono text-[11px] ${configArtifactClass(artifact)}`}
>
{configArtifactLabel(artifact)}
</span>
{artifact.state !== "active" && (
<span
className={`flex h-fit shrink-0 items-center gap-1.5 font-mono text-[11px] ${
artifact.state === "removed"
? "text-rust"
: artifact.state === "pending"
? "text-charcoal/70"
: "text-charcoal/55"
}`}
>
<StatusIcon
kind={artifact.state === "removed"
? "warn"
: artifact.state === "pending"
? "info"
: "unknown"}
size={13}
/>
{artifact.state}
</span>
)}
</div>
);
})}
Expand Down Expand Up @@ -418,6 +434,11 @@ function GateEnforcementCoverage({
exactMatch: boolean;
match?: "exact_app" | "any_source" | "foreign_app" | "unknown_identity" | "none";
};
protectionApi?: {
status?: "ok" | "forbidden" | "not_protected" | "error";
exactMatch?: boolean;
match?: "exact_app" | "any_source" | "foreign_app" | "unknown_identity" | "none";
} | null;
activeRules: {
exactMatch: boolean;
match?: "exact_app" | "any_source" | "foreign_app" | "unknown_identity" | "none";
Expand All @@ -429,74 +450,98 @@ function GateEnforcementCoverage({
}>;
now: Date;
}) {
const enforcementCounts = repositories.reduce(
(counts, repository) => {
const status = deriveGateEnforcementPresentation(
{
status: repository.gateEnforcementStatus,
checkedAt: repository.gateCheckedAt,
lastError: repository.gateLastError,
},
now,
).status;
counts[status] += 1;
const rows = repositories.map((repository) => ({
repository,
presentation: deriveGateEnforcementPresentation(
{
status: repository.gateEnforcementStatus,
checkedAt: repository.gateCheckedAt,
lastError: repository.gateLastError,
},
now,
),
}));
const enforcementCounts = rows.reduce(
(counts, row) => {
counts[row.presentation.status] += 1;
return counts;
},
{ required: 0, not_required: 0, unknown: 0 },
);
// Action-needed states first, then unknowns, then verified rows.
const statusRank = { not_required: 0, unknown: 1, required: 2 } as const;
const sortedRows = [...rows].sort(
(a, b) =>
statusRank[a.presentation.status] - statusRank[b.presentation.status] ||
a.repository.fullName.localeCompare(b.repository.fullName),
);
const statusTone = (status: "required" | "not_required" | "unknown") =>
status === "required"
? "text-gate"
: status === "not_required"
? gateEnabled
? "text-rust"
: "text-charcoal/70"
: "text-charcoal/55";
const statusKind = (status: "required" | "not_required" | "unknown") =>
status === "required"
? ("pass" as const)
: status === "not_required"
? gateEnabled
? ("warn" as const)
: ("info" as const)
: ("unknown" as const);
const countEntries = [
{ status: "not_required" as const, count: enforcementCounts.not_required, label: "not enforced" },
{ status: "unknown" as const, count: enforcementCounts.unknown, label: "unverified" },
{ status: "required" as const, count: enforcementCounts.required, label: "enforced" },
].filter((entry) => entry.count > 0);
return (
<section className="mb-8">
<div className="flex items-center justify-between gap-3">
<p className="eyebrow">Installation health</p>
<GateEnforcementRecheckButton slug={slug} />
</div>
<div className="mt-3 rounded-card border border-stone/70 bg-paper p-4">
<p className="text-sm text-charcoal">
Reviews post a <code>postil/gate</code> check from the Postil GitHub App.
GitHub blocks a merge only when the repository&apos;s branch rules require
that check from this specific App.
</p>
<div className="mt-3 rounded-card border border-stone/70 bg-paper px-4 py-3">
{!gateEnabled && (
<p className="mt-2 text-xs text-charcoal/65">
<p className="mb-2 text-xs text-charcoal/65">
The merge gate is off for this organization, so <code>postil/gate</code>{" "}
reports without blocking regardless of findings. These statuses matter
once the gate is on.
</p>
)}
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 font-mono text-[11px] text-charcoal/65">
<span>{enforcementCounts.required} enforced</span>
<span>{enforcementCounts.not_required} not enforced</span>
<span>{enforcementCounts.unknown} unverified</span>
<div className="flex flex-wrap gap-x-5 gap-y-2">
{countEntries.map((entry) => (
<span
key={entry.status}
className={`flex items-center gap-1.5 font-mono text-[11px] ${statusTone(entry.status)}`}
>
<StatusIcon kind={statusKind(entry.status)} size={13} />
{entry.count} {entry.label}
</span>
))}
</div>
</div>
<div className="card mt-3 divide-y divide-stone/60">
{repositories.map((repository) => {
const presentation = deriveGateEnforcementPresentation(
{
status: repository.gateEnforcementStatus,
checkedAt: repository.gateCheckedAt,
lastError: repository.gateLastError,
},
now,
);
{sortedRows.map(({ repository, presentation }) => {
const plan = buildGateEnforcementDryRunPlan(
presentation,
repository.gateDefaultBranch,
);
const anySource =
repository.gateEvidence?.branchProtection.match === "any_source" ||
repository.gateEvidence?.activeRules.match === "any_source";
const identityUnknown =
repository.gateEvidence?.branchProtection.match === "unknown_identity" ||
repository.gateEvidence?.activeRules.match === "unknown_identity";
const foreignSource =
repository.gateEvidence?.branchProtection.match === "foreign_app" ||
repository.gateEvidence?.activeRules.match === "foreign_app";
const evidence = repository.gateEvidence;
const matches = [
evidence?.branchProtection.match,
evidence?.protectionApi?.match,
evidence?.activeRules.match,
];
const anySource = matches.includes("any_source");
const identityUnknown = matches.includes("unknown_identity");
const foreignSource = matches.includes("foreign_app");
const protectionForbidden = evidence?.protectionApi?.status === "forbidden";
const detail = presentation.status === "required" &&
repository.gateEvidence?.branchProtection.exactMatch
(evidence?.branchProtection.exactMatch || evidence?.protectionApi?.exactMatch)
? "branch protection requires the check from the Postil App"
: presentation.status === "required" &&
repository.gateEvidence?.activeRules.exactMatch
: presentation.status === "required" && evidence?.activeRules.exactMatch
? "an active ruleset requires the check from the Postil App"
: anySource
? "a required check named postil/gate exists, but any app may satisfy it"
Expand All @@ -521,7 +566,7 @@ function GateEnforcementCoverage({
>
{repository.fullName}
</a>
<p className="mt-0.5 text-xs text-charcoal/60">
<p className="mt-0.5 font-mono text-[11px] text-charcoal/55">
{repository.gateDefaultBranch
? `default: ${repository.gateDefaultBranch}`
: "default branch unavailable"}
Expand All @@ -530,20 +575,36 @@ function GateEnforcementCoverage({
: " · not checked"}
</p>
</div>
<span className="flex shrink-0 items-center gap-1.5 rounded-full border border-stone px-2.5 py-0.5 font-mono text-[11px] text-charcoal/70">
<StatusIcon
kind={presentation.status === "required"
? "pass"
: presentation.status === "not_required" && gateEnabled
? "warn"
: "info"}
size={13}
/>
<span
className={`flex shrink-0 items-center gap-1.5 font-mono text-[11px] ${statusTone(presentation.status)} ${
presentation.status === "not_required" && gateEnabled ? "font-medium" : ""
}`}
>
<StatusIcon kind={statusKind(presentation.status)} size={13} />
{presentation.enforcementLabel}
</span>
</div>
<p className="mt-2 text-xs text-charcoal/70">{presentation.consequence}</p>
<p className="mt-0.5 text-xs text-charcoal/55">{detail}</p>
{presentation.status !== "required" && (
<p className="mt-2 text-xs text-charcoal/70">{presentation.consequence}</p>
)}
{presentation.status === "not_required" && gateEnabled && (
<a
href={settingsHref}
className="mt-1 inline-block text-xs text-rust underline"
rel="noopener noreferrer"
>
Open repository rules
</a>
)}
{presentation.status === "unknown" && (
<p className="mt-1 text-xs text-charcoal/55">
{presentation.stale
? "Re-check to refresh this status."
: identityUnknown && protectionForbidden
? "A ruleset naming the Postil App verifies without extra access. Granting the App's optional repository Administration (read-only) permission verifies classic protection too."
: "Re-check, or open the panel below for what Postil could not read."}
</p>
)}
{repository.gateLastError && (
<p className="mt-1 text-xs text-rust">
{repository.gateLastError}
Expand All @@ -561,14 +622,15 @@ function GateEnforcementCoverage({
: "How to enforce"}
</summary>
<div className="mt-3 space-y-2">
<p><strong>Evidence:</strong> {detail}.</p>
{identityUnknown && (
<p>
Classic branch protection does not tell Postil which app a
required check is bound to, so this stays unverified even when
it is set up correctly. A branch ruleset that requires{" "}
<code>postil/gate</code> from the Postil App is verifiable:
recreate the requirement there and remove it from classic
protection.
required check is bound to unless the App&apos;s optional
repository Administration (read-only) permission is granted. A branch
ruleset that requires <code>postil/gate</code> from the Postil
App is verifiable without extra access: recreate the
requirement there and remove it from classic protection.
</p>
)}
<p><strong>Target:</strong> {plan.target}</p>
Expand Down Expand Up @@ -696,24 +758,6 @@ function configArtifactHref(
return null;
}

function configArtifactLabel(artifact: VisibleConfigArtifact): string {
if (artifact.state === "active") {
if (artifact.recordedSource === "shared") return "shared";
return artifact.liveSource === "repository" ? "repo" : "org";
}
return artifact.state;
}

function configArtifactClass(artifact: VisibleConfigArtifact): string {
if (artifact.state === "active") {
return artifact.liveSource === "repository"
? "border-gate text-gate"
: "border-rust text-rust";
}
if (artifact.state === "unverified") return "border-stone text-charcoal/55";
return "border-rust text-rust";
}

function configArtifactDescription(artifact: VisibleConfigArtifact): string {
if (artifact.state === "active") {
if (artifact.liveSource === "repository") {
Expand Down
2 changes: 1 addition & 1 deletion src/components/status-icon.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Image from "next/image";

export type StatusKind = "pass" | "warn" | "error" | "info";
export type StatusKind = "pass" | "warn" | "error" | "info" | "unknown";

/**
* Status iconography from public/status/*.svg (brand set).
Expand Down
Loading