diff --git a/README.md b/README.md
index 19d9e22..6387387 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,10 @@ The GitHub App provides installation tokens for repo-scoped access, webhook deli
- Pull request review
- Pull request review comment
- Pull request review thread
+ - Status
+ - Repository ruleset
+ - Branch protection rule
+ - Workflow run
5. Click **Create GitHub App**
@@ -244,7 +248,10 @@ Expanding permissions after users have installed the app will require those inst
| Pull request review | Yes | Review state and PR detail |
| Pull request review comment | Yes | Diff discussion and review comments |
| Pull request review thread | Yes | Review thread state changes |
-| Workflow run | Later | For Actions dashboard (workflow-run updates) |
+| Status | Yes | Commit statuses (CodeRabbit, CircleCI, etc.) on PR pages |
+| Repository ruleset | Yes | Required status checks & "Expected" check rendering |
+| Branch protection rule | Yes | Required status checks (legacy protection) |
+| Workflow run | Yes | Workflow approval state + Actions dashboard |
| Workflow job | Later | For Actions dashboard (job-level logs) |
| Push | Later | Branch-aware activity features |
| Repository | Later | Repo settings and metadata changes |
diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-header.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-header.tsx
index 8581a17..a7888dc 100644
--- a/apps/dashboard/src/components/issues/detail/issue-detail-header.tsx
+++ b/apps/dashboard/src/components/issues/detail/issue-detail-header.tsx
@@ -1,6 +1,9 @@
import { IssuesIcon } from "@diffkit/icons";
import { Markdown } from "@diffkit/ui/components/markdown";
-import { cn } from "@diffkit/ui/lib/utils";
+import {
+ StatePill,
+ type StatePillTone,
+} from "@diffkit/ui/components/state-pill";
import { useState } from "react";
import { IssueCommentReactionBar } from "#/components/details/comment-reaction-bar";
import { DetailPageTitle } from "#/components/details/detail-page";
@@ -12,7 +15,7 @@ import { usePrefersNoHover } from "#/lib/use-prefers-no-hover";
type IssueStateConfig = {
color: string;
label: string;
- badgeClass: string;
+ tone: StatePillTone;
};
export function getIssueStateConfig(issue: IssueDetail): IssueStateConfig {
@@ -21,19 +24,19 @@ export function getIssueStateConfig(issue: IssueDetail): IssueStateConfig {
return {
color: "text-muted-foreground",
label: "Closed",
- badgeClass: "bg-muted text-muted-foreground",
+ tone: "muted",
};
}
return {
color: "text-purple-500",
label: "Closed",
- badgeClass: "bg-purple-500/10 text-purple-500",
+ tone: "merged",
};
}
return {
color: "text-green-500",
label: "Open",
- badgeClass: "bg-green-500/10 text-green-500",
+ tone: "open",
};
}
@@ -67,14 +70,7 @@ export function IssueDetailHeader({
title={issue.title}
subtitle={
-
- {stateConfig.label}
-
+
{stateConfig.label}
{issue.author && (
0;
const allChecksPassed =
- checks.total > 0 && checks.failed === 0 && checks.pending === 0;
+ checks.total > 0 &&
+ checks.failed === 0 &&
+ checks.pending === 0 &&
+ checks.expected === 0;
const hasCheckFailures = checks.failed > 0;
const isBehind = behindBy !== null && behindBy > 0;
const hasConflicts = mergeableState === "dirty";
@@ -489,12 +499,16 @@ function MergeStatusCard({
/>
{/* Checks section */}
- {checks.total > 0 && (
+ {(checks.total > 0 || pendingWorkflowApprovals.length > 0) && (
)}
@@ -737,15 +751,30 @@ function ReviewsSection({
function ChecksSection({
checks,
checkRuns,
+ pendingWorkflowApprovals,
allChecksPassed,
hasCheckFailures,
+ owner,
+ repo,
+ pullNumber,
}: {
checks: PullStatus["checks"];
checkRuns: PullCheckRun[];
+ pendingWorkflowApprovals: PullWorkflowApproval[];
allChecksPassed: boolean;
hasCheckFailures: boolean;
+ owner: string;
+ repo: string;
+ pullNumber: number;
}) {
const [open, setOpen] = useState(true);
+ const [isRerunning, setIsRerunning] = useState(false);
+ const [isApproving, setIsApproving] = useState(false);
+ const queryClient = useQueryClient();
+
+ const pendingTotal = checks.pending + checks.expected;
+ const approvalCount = pendingWorkflowApprovals.length;
+ const isApprovalOnly = checks.total === 0 && approvalCount > 0;
const checkStatus: StatusType = allChecksPassed
? "success"
@@ -753,36 +782,137 @@ function ChecksSection({
? "error"
: "pending";
- const title = allChecksPassed
- ? "All checks have passed"
- : hasCheckFailures
- ? `${checks.failed} failing check${checks.failed > 1 ? "s" : ""}`
- : `${checks.pending} pending check${checks.pending > 1 ? "s" : ""}`;
+ const title = isApprovalOnly
+ ? `${approvalCount} workflow${approvalCount !== 1 ? "s" : ""} awaiting approval`
+ : allChecksPassed
+ ? "All checks have passed"
+ : hasCheckFailures
+ ? `${checks.failed} failing check${checks.failed > 1 ? "s" : ""}`
+ : `${pendingTotal} pending check${pendingTotal > 1 ? "s" : ""}`;
+
+ let description: string;
+ if (isApprovalOnly) {
+ description = "Approve to run workflows and report checks.";
+ } else {
+ const parts: string[] = [];
+ if (checks.skipped > 0) parts.push(`${checks.skipped} skipped`);
+ parts.push(
+ `${checks.passed} successful check${checks.passed !== 1 ? "s" : ""}`,
+ );
+ if (checks.pending > 0) parts.push(`${checks.pending} pending`);
+ if (checks.expected > 0) parts.push(`${checks.expected} expected`);
+ if (checks.failed > 0) parts.push(`${checks.failed} failing`);
+ description = parts.join(", ");
+ }
- const parts: string[] = [];
- if (checks.skipped > 0) parts.push(`${checks.skipped} skipped`);
- parts.push(
- `${checks.passed} successful check${checks.passed !== 1 ? "s" : ""}`,
- );
- if (checks.pending > 0) parts.push(`${checks.pending} pending`);
- if (checks.failed > 0) parts.push(`${checks.failed} failing`);
- const description = parts.join(", ");
-
- // Sort: failed first, then pending, then skipped, then passed
- const sortedRuns = [...checkRuns].sort((a, b) => {
- const order = (run: PullCheckRun) => {
- if (run.status !== "completed") return 1;
- if (
- run.conclusion === "failure" ||
- run.conclusion === "timed_out" ||
- run.conclusion === "cancelled"
- )
- return 0;
- if (run.conclusion === "skipped") return 2;
- return 3;
+ // Group by status in display order: expected, failed, pending, skipped, passed
+ const groupedRuns = useMemo(() => {
+ const groups: Record<
+ "expected" | "failure" | "pending" | "skipped" | "success",
+ PullCheckRun[]
+ > = {
+ expected: [],
+ failure: [],
+ pending: [],
+ skipped: [],
+ success: [],
};
- return order(a) - order(b);
- });
+ for (const run of checkRuns) {
+ groups[getCheckRunStatus(run)].push(run);
+ }
+ return groups;
+ }, [checkRuns]);
+
+ const checkRunGroups: Array<{
+ key: keyof typeof groupedRuns;
+ label: string;
+ }> = [
+ { key: "expected", label: "Expected" },
+ { key: "failure", label: "Failed" },
+ { key: "pending", label: "Pending" },
+ { key: "skipped", label: "Skipped" },
+ { key: "success", label: "Passed" },
+ ];
+
+ const handleRerun = async (failedOnly: boolean) => {
+ setIsRerunning(true);
+ try {
+ const result = await rerunChecks({
+ data: { owner, repo, pullNumber, failedOnly },
+ });
+ if (result.ok) {
+ const rerun = result.rerun ?? 0;
+ const skipped = result.skipped ?? 0;
+ const failed = result.failed ?? 0;
+ if (result.partial) {
+ toast.warning(
+ `Re-running ${rerun} check${rerun !== 1 ? "s" : ""}, but ${failed} failed`,
+ );
+ } else if (rerun > 0 && skipped > 0) {
+ toast.success(
+ `Re-running ${rerun} check${rerun !== 1 ? "s" : ""} · ${skipped} not eligible`,
+ );
+ } else if (rerun > 0) {
+ toast.success(`Re-running ${rerun} check${rerun !== 1 ? "s" : ""}`);
+ } else if (skipped > 0) {
+ toast.info("No checks are eligible for rerun");
+ }
+ await queryClient.invalidateQueries({ queryKey: ["github"] });
+ } else {
+ toast.error(result.error);
+ checkPermissionWarning(result, `${owner}/${repo}`);
+ }
+ } catch {
+ toast.error("Failed to rerun checks");
+ } finally {
+ setIsRerunning(false);
+ }
+ };
+
+ const handleApprove = async () => {
+ setIsApproving(true);
+ try {
+ const result = await approveWorkflowRuns({
+ data: {
+ owner,
+ repo,
+ pullNumber,
+ workflowRunIds: pendingWorkflowApprovals.map((a) => a.workflowRunId),
+ },
+ });
+ if (result.ok) {
+ if (result.partial) {
+ const approved = result.approved ?? 0;
+ const failed = result.failed ?? 0;
+ toast.warning(
+ `Approved ${approved} workflow${approved !== 1 ? "s" : ""}, but ${failed} failed`,
+ );
+ }
+ // Keep the button in loading state; the effect below resets it once the
+ // workflow_run webhook invalidates the cache and the pending list drains.
+ await queryClient.invalidateQueries({ queryKey: ["github"] });
+ } else {
+ toast.error(result.error);
+ checkPermissionWarning(result, `${owner}/${repo}`);
+ setIsApproving(false);
+ }
+ } catch {
+ toast.error("Failed to approve workflows");
+ setIsApproving(false);
+ }
+ };
+
+ // Reset the approving state when the pending list drains (webhook arrived) or
+ // after a safety timeout to avoid a permanently-stuck spinner.
+ useEffect(() => {
+ if (!isApproving) return;
+ if (pendingWorkflowApprovals.length === 0) {
+ setIsApproving(false);
+ return;
+ }
+ const timer = setTimeout(() => setIsApproving(false), 30_000);
+ return () => clearTimeout(timer);
+ }, [isApproving, pendingWorkflowApprovals.length]);
return (
@@ -807,56 +937,168 @@ function ChecksSection({
+ {pendingWorkflowApprovals.length > 0 && (
+
+
+
+
+ {pendingWorkflowApprovals.length} workflow
+ {pendingWorkflowApprovals.length !== 1 ? "s" : ""} awaiting
+ approval
+
+
+ This workflow requires approval from a maintainer.
+
+
+
: undefined}
+ onClick={() => void handleApprove()}
+ >
+ Approve workflows to run
+
+
+ )}
-
- {sortedRuns.map((run) => {
- const runStatus = getCheckRunStatus(run);
+
+ {checkRunGroups.map(({ key, label }) => {
+ const runs = groupedRuns[key];
+ if (runs.length === 0) return null;
return (
-
-
- {run.appAvatarUrl && (
-

- )}
-
- {run.name}
-
- {runStatus === "pending" && run.startedAt
- ? ` — Started ${formatRelativeTime(run.startedAt)}`
- : run.outputTitle
- ? ` — ${run.outputTitle}`
- : null}
-
-
-
- {runStatus === "success"
- ? "Passed"
- : runStatus === "failure"
- ? "Failed"
- : runStatus === "pending"
- ? "Pending"
- : "Skipped"}
-
+
+
+ {label}
+
+ {runs.map((run) => {
+ const runStatus = getCheckRunStatus(run);
+ const detail =
+ runStatus === "expected"
+ ? "Waiting for status to be reported"
+ : runStatus === "pending" && run.startedAt
+ ? `Started ${formatRelativeTime(run.startedAt)}`
+ : run.outputTitle;
+ const nameContent = (
+ <>
+
{run.name}
+ {detail && (
+
+ {" — "}
+ {detail}
+
+ )}
+ >
+ );
+ return (
+
+
+ {run.appAvatarUrl && (
+

+ )}
+ {run.htmlUrl ? (
+
+ {nameContent}
+
+ ) : (
+
+ {nameContent}
+
+ )}
+ {run.required && (
+
+ Required
+
+ )}
+
+ {runStatus === "success"
+ ? "Passed"
+ : runStatus === "failure"
+ ? "Failed"
+ : runStatus === "pending"
+ ? "Pending"
+ : runStatus === "expected"
+ ? "Expected"
+ : "Skipped"}
+
+
+ );
+ })}
);
})}
+ {(hasCheckFailures || checks.total > 0) && (
+
+ {hasCheckFailures && (
+
+ ) : (
+
+ )
+ }
+ onClick={() => void handleRerun(true)}
+ >
+ Re-run failed checks
+
+ )}
+ {checks.total > 0 && (
+
+ ) : (
+
+ )
+ }
+ onClick={() => void handleRerun(false)}
+ >
+ Re-run all checks
+
+ )}
+
+ )}
);
@@ -1296,7 +1538,7 @@ function StatusIcon({ status }: { status: StatusType }) {
function CheckRunIcon({
status,
}: {
- status: "success" | "failure" | "pending" | "skipped";
+ status: "success" | "failure" | "pending" | "skipped" | "expected";
}) {
if (status === "success") {
return (
@@ -1319,6 +1561,13 @@ function CheckRunIcon({
);
}
+ if (status === "expected") {
+ return (
+
+ );
+ }
return (