From c9c7a8b6cbc8c09a20a16917204ff59f49d07560 Mon Sep 17 00:00:00 2001
From: Kalven Schraut
Date: Thu, 10 Sep 2026 09:50:38 -0500
Subject: [PATCH 1/3] feat(pull-requests): inspect and rerun CI inline
---
.../features/threads/git/GitOverviewSheet.tsx | 20 ++
.../threads/git/PullRequestCiSection.tsx | 272 ++++++++++++++++++
apps/mobile/src/state/pull-request-ci.ts | 5 +
apps/server/src/auth/RpcAuthorization.test.ts | 11 +
apps/server/src/auth/RpcAuthorization.ts | 3 +
apps/server/src/pullRequest/ActionsCi.test.ts | 269 +++++++++++++++++
apps/server/src/pullRequest/ActionsCi.ts | 250 ++++++++++++++++
.../pullRequest/GitHubPullRequestCli.test.ts | 35 +++
.../src/pullRequest/GitHubPullRequestCli.ts | 22 +-
.../pullRequest/GitHubPullRequestProvider.ts | 4 +
.../pullRequest/GiteaPullRequestApi.test.ts | 72 +++++
.../src/pullRequest/GiteaPullRequestApi.ts | 15 +-
.../pullRequest/GiteaPullRequestProvider.ts | 4 +
.../src/pullRequest/PullRequestProvider.ts | 18 +-
.../pullRequest/PullRequestService.test.ts | 78 +++++
.../src/pullRequest/PullRequestService.ts | 62 ++++
apps/server/src/ws.ts | 12 +
.../pullRequest/PullRequestChecksPopover.tsx | 44 ++-
.../pullRequest/PullRequestCiRuns.test.tsx | 146 ++++++++++
.../pullRequest/PullRequestCiRuns.tsx | 237 +++++++++++++++
.../pullRequest/PullRequestDetailPanel.tsx | 18 +-
.../components/pullRequest/PullRequestRow.tsx | 20 +-
.../pullRequest/PullRequestSummaryTab.tsx | 8 +
.../pullRequest/pullRequestChecks.test.tsx | 4 +-
docs/user/source-control.md | 6 +
.../src/state/pullRequests.test.ts | 244 +++++++++++++++-
.../client-runtime/src/state/pullRequests.ts | 145 ++++++++--
packages/contracts/src/pullRequest.test.ts | 16 ++
packages/contracts/src/pullRequest.ts | 56 ++++
packages/contracts/src/rpc.ts | 28 ++
30 files changed, 2067 insertions(+), 57 deletions(-)
create mode 100644 apps/mobile/src/features/threads/git/PullRequestCiSection.tsx
create mode 100644 apps/mobile/src/state/pull-request-ci.ts
create mode 100644 apps/server/src/pullRequest/ActionsCi.test.ts
create mode 100644 apps/server/src/pullRequest/ActionsCi.ts
create mode 100644 apps/web/src/components/pullRequest/PullRequestCiRuns.test.tsx
create mode 100644 apps/web/src/components/pullRequest/PullRequestCiRuns.tsx
diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
index 881f461f29c6..69c0f583c570 100644
--- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
+++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
@@ -35,6 +35,7 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo
import { vcsEnvironment } from "../../../state/vcs";
import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation";
import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
+import { PullRequestCiSection } from "./PullRequestCiSection";
const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
@@ -80,6 +81,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
);
const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
+ const pullRequest = selectedThread?.linkedPullRequest ?? selectedThread?.branchPullRequest;
const currentStatusSummary = statusSummary(gitStatus.data);
const currentWorktreePath = selectedThreadWorktreePath;
const gitOperationLabel = gitState.gitOperationLabel;
@@ -335,6 +337,17 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
});
}}
/>
+ {selectedThread !== null && (
+
+ )}
))}
@@ -342,6 +355,13 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
) : null}
+ {linkedPrChains.length === 0 && pullRequest && (
+
+ )}
{currentWorktreePath ? : null}
);
diff --git a/apps/mobile/src/features/threads/git/PullRequestCiSection.tsx b/apps/mobile/src/features/threads/git/PullRequestCiSection.tsx
new file mode 100644
index 000000000000..dfaf73cd71ff
--- /dev/null
+++ b/apps/mobile/src/features/threads/git/PullRequestCiSection.tsx
@@ -0,0 +1,272 @@
+import { useAtomValue } from "@effect/atom-react";
+import type {
+ EnvironmentId,
+ PullRequestCiRun,
+ PullRequestCiRunInput,
+ PullRequestCiRerunTarget,
+ PullRequestRef,
+} from "@t3tools/contracts";
+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
+import { useState } from "react";
+import { Alert, Pressable, View } from "react-native";
+
+import { AppText as Text } from "../../../components/AppText";
+import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
+import { pullRequestCiEnvironment } from "../../../state/pull-request-ci";
+import { useEnvironmentQuery } from "../../../state/query";
+import { useAtomCommand } from "../../../state/use-atom-command";
+
+function Action({
+ label,
+ disabled,
+ onPress,
+}: {
+ label: string;
+ disabled?: boolean;
+ onPress: () => void;
+}) {
+ return (
+
+ {label}
+
+ );
+}
+
+function open(url: string) {
+ void tryOpenExternalUrl(url, "pull-request").then((opened) => {
+ if (!opened) Alert.alert("Unable to open CI details");
+ });
+}
+
+function Jobs({
+ environmentId,
+ input,
+ disabled,
+ onRerun,
+}: {
+ environmentId: EnvironmentId;
+ input: PullRequestCiRunInput;
+ disabled: boolean;
+ onRerun: (target: PullRequestCiRerunTarget) => void;
+}) {
+ const query = useEnvironmentQuery(pullRequestCiEnvironment.ciJobs({ environmentId, input }));
+ if (query.error)
+ return (
+
+ {query.error}
+
+ );
+ if (!query.data) return Loading jobs… ;
+ return (
+
+ {query.data.jobs.map((job) => (
+
+
+ job.url && open(job.url)} />
+ {job.status}
+
+ {job.canRerun && (
+ onRerun({ kind: "job", jobId: job.id })}
+ />
+ )}
+
+ ))}
+ {query.data.truncated && (
+ More jobs are available on the host.
+ )}
+
+ Rerunning a job may also rerun dependent jobs.
+
+
+ );
+}
+
+function Run({
+ environmentId,
+ reference,
+ headSha,
+ run,
+ disabled,
+}: {
+ environmentId: EnvironmentId;
+ reference: PullRequestRef;
+ headSha: string;
+ run: PullRequestCiRun;
+ disabled: boolean;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const rerun = useAtomCommand(pullRequestCiEnvironment.rerunCi, { reportFailure: false });
+ const input = { ...reference, runId: run.id, headSha, attempt: run.attempt };
+ const submission = useAtomValue(pullRequestCiEnvironment.ciRerunState({ environmentId, input }));
+ const pending = submission === "pending";
+ const requested = submission === "requested";
+ const submit = async (target: PullRequestCiRerunTarget) => {
+ if (pending || requested || disabled) return;
+ const result = await rerun({ environmentId, input: { ...input, target } });
+ if (result._tag === "Failure") {
+ const error = squashAtomCommandFailure(result);
+ Alert.alert(
+ "Unable to rerun CI",
+ error instanceof Error ? error.message : "The host refused the rerun.",
+ );
+ }
+ };
+ return (
+
+ setExpanded(!expanded)}
+ className="min-h-11 justify-center"
+ >
+ {run.name}
+
+
+ {pending ? "Requesting rerun…" : requested ? "Rerun requested" : run.status}
+
+
+ {run.url && open(run.url!)} />}
+ {run.rerunModes.map((kind) => (
+ void submit({ kind })}
+ />
+ ))}
+
+ {expanded && (
+ void submit(target)}
+ />
+ )}
+
+ );
+}
+
+function Runs({
+ environmentId,
+ reference,
+}: {
+ environmentId: EnvironmentId;
+ reference: PullRequestRef;
+}) {
+ const query = useEnvironmentQuery(
+ pullRequestCiEnvironment.ciRuns({ environmentId, input: reference }),
+ );
+ const invalidate = useAtomCommand(pullRequestCiEnvironment.invalidate);
+ return (
+
+ {
+ void invalidate({ environmentId, input: { reference } });
+ }}
+ />
+ {query.error && (
+
+ {query.error}
+
+ )}
+ {query.data ? (
+ <>
+ {query.data.runs.map((run) => (
+
+ ))}
+ {query.data.runs.length === 0 && (
+ No CI runs for this revision.
+ )}
+ {query.data.truncated && (
+
+ More runs are available on the host.
+
+ )}
+ >
+ ) : query.isPending ? (
+ Loading CI runs…
+ ) : null}
+
+ );
+}
+
+function Checks({
+ environmentId,
+ reference,
+}: {
+ environmentId: EnvironmentId;
+ reference: PullRequestRef;
+}) {
+ const query = useEnvironmentQuery(
+ pullRequestCiEnvironment.detail({ environmentId, input: reference }),
+ );
+ if (query.error)
+ return (
+
+ {query.error}
+
+ );
+ if (!query.data) return Loading checks… ;
+ return (
+
+ {query.data.capabilities.ciRuns ? (
+
+ ) : null}
+ {query.data.checks.map((check) => (
+
+ check.url && open(check.url)}
+ />
+ {check.status}
+
+ ))}
+ {!query.data.capabilities.ciRuns && query.data.checks.length === 0 && (
+ No checks reported.
+ )}
+
+ );
+}
+
+export function PullRequestCiSection({
+ environmentId,
+ reference,
+}: {
+ environmentId: EnvironmentId;
+ reference: PullRequestRef;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ return (
+
+ setExpanded(!expanded)}
+ className="min-h-11 justify-center"
+ >
+ CI runs and checks
+
+ {expanded && }
+
+ );
+}
diff --git a/apps/mobile/src/state/pull-request-ci.ts b/apps/mobile/src/state/pull-request-ci.ts
new file mode 100644
index 000000000000..8261cc865036
--- /dev/null
+++ b/apps/mobile/src/state/pull-request-ci.ts
@@ -0,0 +1,5 @@
+import { createPullRequestCiEnvironmentAtoms } from "@t3tools/client-runtime/state/pull-requests";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const pullRequestCiEnvironment = createPullRequestCiEnvironmentAtoms(connectionAtomRuntime);
diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts
index b4bee2cfc30e..414124c42cab 100644
--- a/apps/server/src/auth/RpcAuthorization.test.ts
+++ b/apps/server/src/auth/RpcAuthorization.test.ts
@@ -11,6 +11,17 @@ import { describe, expect, it } from "@effect/vitest";
import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod } from "./RpcAuthorization.ts";
describe("RPC authorization scopes", () => {
+ it("lets readers inspect CI but requires operate scope to rerun it", () => {
+ expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsCiRuns)).toBe(
+ AuthOrchestrationReadScope,
+ );
+ expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsCiJobs)).toBe(
+ AuthOrchestrationReadScope,
+ );
+ expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsRerunCi)).toBe(
+ AuthOrchestrationOperateScope,
+ );
+ });
it("declares exactly one scope for every RPC in the server group", () => {
expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));
});
diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts
index 47c5a87a20bf..a83e285593c3 100644
--- a/apps/server/src/auth/RpcAuthorization.ts
+++ b/apps/server/src/auth/RpcAuthorization.ts
@@ -71,6 +71,9 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
+ [WS_METHODS.pullRequestsCiRuns]: AuthOrchestrationReadScope,
+ [WS_METHODS.pullRequestsCiJobs]: AuthOrchestrationReadScope,
+ [WS_METHODS.pullRequestsRerunCi]: AuthOrchestrationOperateScope,
[WS_METHODS.pullRequestsDependencyContext]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
diff --git a/apps/server/src/pullRequest/ActionsCi.test.ts b/apps/server/src/pullRequest/ActionsCi.test.ts
new file mode 100644
index 000000000000..0e89da435b17
--- /dev/null
+++ b/apps/server/src/pullRequest/ActionsCi.test.ts
@@ -0,0 +1,269 @@
+import { expect, it, describe } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+
+import { actionsCiStatus, makeActionsCi, resolveCheckUrl } from "./ActionsCi.ts";
+
+const ref = { cwd: "/workspace", host: "forge.test", repository: "acme/web", number: 42 };
+const runRef = { ...ref, runId: "12", headSha: "head", attempt: 2 };
+const run = {
+ id: 12,
+ head_sha: "head",
+ name: "CI",
+ html_url: "https://forge.test/acme/web/actions/runs/12",
+ status: "completed",
+ conclusion: "failure",
+ run_attempt: 2,
+ pull_requests: [{ number: 42 }],
+};
+const job = {
+ id: 93,
+ run_id: 12,
+ run_attempt: 2,
+ name: "Test",
+ html_url: "https://forge.test/acme/web/actions/runs/12/jobs/999",
+ status: "completed",
+ conclusion: "failure",
+};
+
+function fixture(
+ options: {
+ kind?: "github" | "gitea";
+ head?: string;
+ mergeHead?: string;
+ writable?: boolean;
+ currentRun?: { [K in keyof typeof run]?: (typeof run)[K] | undefined };
+ currentJob?: Partial;
+ runs?: ReadonlyArray<
+ Partial & Pick
+ >;
+ jobPages?: ReadonlyArray>;
+ writeError?: boolean;
+ } = {},
+) {
+ const requests: Array<{ method: string; path: string }> = [];
+ const api = makeActionsCi({
+ kind: options.kind ?? "gitea",
+ fail: (detail) => new Error(detail),
+ request: (input) => {
+ requests.push(input);
+ if (input.method === "POST")
+ return options.writeError
+ ? Effect.fail(new Error("HTTP 403"))
+ : Effect.succeed({ body: "", truncated: false });
+ let data: unknown;
+ if (input.path.endsWith("/pulls/42"))
+ data = { head: { sha: options.head ?? "head" }, merge_commit_sha: options.mergeHead };
+ else if (input.path === "/repos/acme/web")
+ data = { permissions: { push: options.writable !== false } };
+ else if (input.path.includes("/actions/runs?"))
+ data = { total_count: options.runs?.length ?? 1, workflow_runs: options.runs ?? [run] };
+ else if (input.path.endsWith("/actions/runs/12")) data = { ...run, ...options.currentRun };
+ else if (input.path.includes("/actions/runs/12/jobs?")) {
+ const page = Number(new URL(input.path, "https://forge.test").searchParams.get("page"));
+ const pages = options.jobPages ?? [[job]];
+ data = { total_count: pages.flat().length, jobs: pages[page - 1] ?? [] };
+ } else if (input.path.endsWith("/actions/jobs/93")) data = { ...job, ...options.currentJob };
+ else return Effect.fail(new Error(`Unexpected request: ${input.path}`));
+ return Effect.succeed({ body: JSON.stringify(data), truncated: false });
+ },
+ });
+ return { api, requests, writes: () => requests.filter((r) => r.method === "POST") };
+}
+
+describe("Actions CI", () => {
+ it.effect("accepts Gitea's missing conclusion on queued runs and jobs", () =>
+ Effect.gen(function* () {
+ const { conclusion: _runConclusion, ...queuedRun } = { ...run, status: "queued" };
+ const { conclusion: _jobConclusion, ...queuedJob } = { ...job, status: "queued" };
+ const api = makeActionsCi({
+ kind: "gitea",
+ fail: (detail) => new Error(detail),
+ request: (input) => {
+ const body = input.path.endsWith("/pulls/42")
+ ? { head: { sha: "head" } }
+ : input.path === "/repos/acme/web"
+ ? { permissions: { push: true } }
+ : input.path.includes("/jobs?")
+ ? { total_count: 1, jobs: [queuedJob] }
+ : input.path.includes("/runs?")
+ ? { total_count: 1, workflow_runs: [queuedRun] }
+ : queuedRun;
+ return Effect.succeed({ body: JSON.stringify(body), truncated: false });
+ },
+ });
+ const runs = yield* api.getCiRuns(ref);
+ expect(runs.runs[0]?.status).toBe("pending");
+ expect(runs.runs[0]?.rerunModes).toEqual([]);
+ const jobs = yield* api.getCiJobs(runRef);
+ expect(jobs.jobs[0]?.status).toBe("pending");
+ expect(jobs.jobs[0]?.canRerun).toBe(false);
+ }),
+ );
+ it.effect("lists only this PR revision without fetching jobs", () =>
+ Effect.gen(function* () {
+ const f = fixture({
+ runs: [
+ run,
+ { ...run, id: 13, head_sha: "old" },
+ { ...run, id: 14, pull_requests: [{ number: 99 }] },
+ ],
+ });
+ const result = yield* f.api.getCiRuns(ref);
+ expect(result.runs.map((r) => r.id)).toEqual(["12"]);
+ expect(result.runs[0]?.rerunModes).toEqual(["all", "failed"]);
+ expect(f.requests.some((r) => r.path.includes("/jobs"))).toBe(false);
+ }),
+ );
+
+ it.effect(
+ "includes merge-revision workflows only when the host associates them with this PR",
+ () =>
+ Effect.gen(function* () {
+ const f = fixture({
+ kind: "github",
+ mergeHead: "merge",
+ runs: [
+ run,
+ { ...run, id: 13, head_sha: "merge" },
+ { ...run, id: 14, head_sha: "merge", pull_requests: [] },
+ ],
+ });
+ expect((yield* f.api.getCiRuns(ref)).runs.map((entry) => entry.id)).toEqual(["13", "12"]);
+ yield* f.api.rerunCi({ ...runRef, target: { kind: "all" } });
+ expect(
+ f.requests
+ .filter((entry) => entry.path.includes("/actions/runs?"))
+ .map((entry) => entry.path),
+ ).toContain("/repos/acme/web/actions/runs?head_sha=merge&per_page=50&limit=50");
+ }),
+ );
+
+ for (const attempt of [undefined, 0]) {
+ it.effect(`reruns jobs on legacy Gitea runs with attempt ${attempt}`, () =>
+ Effect.gen(function* () {
+ const legacyRun = {
+ id: run.id,
+ head_sha: run.head_sha,
+ html_url: run.html_url,
+ status: "completed",
+ conclusion: "failure",
+ ...(attempt === undefined ? {} : { run_attempt: attempt }),
+ };
+ const f = fixture({
+ kind: "gitea",
+ runs: [legacyRun],
+ currentRun: {
+ ...legacyRun,
+ run_attempt: attempt,
+ name: undefined,
+ pull_requests: undefined,
+ },
+ currentJob: { run_attempt: 3 },
+ });
+ const result = yield* f.api.getCiRuns(ref);
+ expect(result.runs[0]).toMatchObject({ id: "12", name: "Run 12", attempt: 0 });
+ yield* f.api.rerunCi({ ...runRef, attempt: 0, target: { kind: "job", jobId: "93" } });
+ expect(f.writes()[0]?.path).toBe("/repos/acme/web/actions/runs/12/jobs/93/rerun");
+ }),
+ );
+ }
+
+ it.effect("uses capped job pages and API IDs instead of job URL numbers", () =>
+ Effect.gen(function* () {
+ const f = fixture({ jobPages: [[job], [{ ...job, id: 94 }]] });
+ const result = yield* f.api.getCiJobs(runRef);
+ expect(result.jobs.map((j) => j.id)).toEqual(["93", "94"]);
+ expect(result.truncated).toBe(false);
+ }),
+ );
+
+ it.effect("does not offer reruns for jobs left over from older attempts", () =>
+ Effect.gen(function* () {
+ const f = fixture({ jobPages: [[job, { ...job, id: 94, run_attempt: 1 }]] });
+ expect((yield* f.api.getCiJobs(runRef)).jobs.map((entry) => entry.canRerun)).toEqual([
+ true,
+ false,
+ ]);
+ }),
+ );
+
+ for (const kind of ["github", "gitea"] as const) {
+ it.effect(`${kind} reruns a native job through its own route`, () =>
+ Effect.gen(function* () {
+ const f = fixture({ kind });
+ yield* f.api.rerunCi({ ...runRef, target: { kind: "job", jobId: "93" } });
+ expect(f.writes()).toHaveLength(1);
+ expect(f.writes()[0]?.path).toBe(
+ kind === "github"
+ ? "/repos/acme/web/actions/jobs/93/rerun"
+ : "/repos/acme/web/actions/runs/12/jobs/93/rerun",
+ );
+ }),
+ );
+ }
+
+ for (const target of ["all", "failed"] as const) {
+ it.effect(`reruns ${target} jobs and accepts an empty success body`, () =>
+ Effect.gen(function* () {
+ const f = fixture();
+ yield* f.api.rerunCi({ ...runRef, target: { kind: target } });
+ expect(f.writes()[0]?.path).toBe(
+ `/repos/acme/web/actions/runs/12/${target === "all" ? "rerun" : "rerun-failed-jobs"}`,
+ );
+ }),
+ );
+ }
+
+ for (const [name, options] of [
+ ["changed PR head", { head: "new-head" }],
+ ["newer run attempt", { currentRun: { run_attempt: 3 } }],
+ ["unrelated run", { currentRun: { head_sha: "different" } }],
+ ["running workflow", { currentRun: { status: "in_progress" } }],
+ ["read-only viewer", { writable: false }],
+ ["job from another run", { currentJob: { run_id: 13 } }],
+ ["job from an old attempt", { currentJob: { run_attempt: 1 } }],
+ ] as const) {
+ it.effect(`refuses ${name} before writing`, () =>
+ Effect.gen(function* () {
+ const f = fixture(options);
+ yield* f.api.rerunCi({ ...runRef, target: { kind: "job", jobId: "93" } }).pipe(Effect.flip);
+ expect(f.writes()).toEqual([]);
+ }),
+ );
+ }
+
+ it.effect("does not offer mutations to a read-only viewer and preserves write failures", () =>
+ Effect.gen(function* () {
+ const reader = fixture({ writable: false });
+ expect((yield* reader.api.getCiRuns(ref)).runs[0]?.rerunModes).toEqual([]);
+ expect((yield* reader.api.getCiJobs(runRef)).jobs[0]?.canRerun).toBe(false);
+ const writer = fixture({ writeError: true });
+ const error = yield* writer.api
+ .rerunCi({ ...runRef, target: { kind: "all" } })
+ .pipe(Effect.flip);
+ expect(error.message).toBe("HTTP 403");
+ expect(writer.writes()).toHaveLength(1);
+ }),
+ );
+});
+
+it("resolves relative check links against the forge including proxy subpaths", () => {
+ expect(resolveCheckUrl("/gitea/acme/web/actions/runs/1", "https://forge.test/gitea")).toBe(
+ "https://forge.test/gitea/acme/web/actions/runs/1",
+ );
+ expect(resolveCheckUrl("acme/web/actions/runs/1", "http://forge.test/gitea")).toBe(
+ "http://forge.test/gitea/acme/web/actions/runs/1",
+ );
+ expect(resolveCheckUrl("https://ci.test/job/1", "https://forge.test/gitea")).toBe(
+ "https://ci.test/job/1",
+ );
+ expect(resolveCheckUrl("javascript:alert(1)", "https://forge.test")).toBeNull();
+});
+
+it("distinguishes active, failed, cancelled, skipped, and approval states", () => {
+ expect(actionsCiStatus("in_progress", null)).toBe("pending");
+ expect(actionsCiStatus("completed", "timed_out")).toBe("failure");
+ expect(actionsCiStatus("completed", "cancelled")).toBe("cancelled");
+ expect(actionsCiStatus("completed", "skipped")).toBe("skipped");
+ expect(actionsCiStatus("completed", "action_required")).toBe("action-required");
+});
diff --git a/apps/server/src/pullRequest/ActionsCi.ts b/apps/server/src/pullRequest/ActionsCi.ts
new file mode 100644
index 000000000000..435f5d354cdf
--- /dev/null
+++ b/apps/server/src/pullRequest/ActionsCi.ts
@@ -0,0 +1,250 @@
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import {
+ NonNegativeInt,
+ PositiveInt,
+ type PullRequestCheckStatus,
+ type PullRequestCiRun,
+ type PullRequestCiRunInput,
+} from "@t3tools/contracts";
+
+import type { ProviderRepositoryRef, ProviderCiApi } from "./PullRequestProvider.ts";
+
+type PullRef = ProviderRepositoryRef & { readonly number: number };
+type RunRef = PullRef & Omit;
+
+const Pull = Schema.Struct({
+ head: Schema.Struct({ sha: Schema.String }),
+ merge_commit_sha: Schema.optional(Schema.NullOr(Schema.String)),
+});
+const Repository = Schema.Struct({
+ permissions: Schema.optional(
+ Schema.Struct({
+ push: Schema.optional(Schema.Boolean),
+ admin: Schema.optional(Schema.Boolean),
+ }),
+ ),
+});
+const Run = Schema.Struct({
+ id: PositiveInt,
+ name: Schema.optional(Schema.NullOr(Schema.String)),
+ path: Schema.optional(Schema.String),
+ display_title: Schema.optional(Schema.String),
+ head_sha: Schema.String,
+ pull_requests: Schema.optional(Schema.Array(Schema.Struct({ number: PositiveInt }))),
+ html_url: Schema.String,
+ status: Schema.String,
+ conclusion: Schema.optional(Schema.NullOr(Schema.String)),
+ run_attempt: Schema.optional(NonNegativeInt),
+});
+const Runs = Schema.Struct({ total_count: NonNegativeInt, workflow_runs: Schema.Array(Run) });
+const Job = Schema.Struct({
+ id: PositiveInt,
+ run_id: PositiveInt,
+ run_attempt: Schema.optional(NonNegativeInt),
+ name: Schema.String,
+ html_url: Schema.String,
+ status: Schema.String,
+ conclusion: Schema.optional(Schema.NullOr(Schema.String)),
+});
+const Jobs = Schema.Struct({ total_count: NonNegativeInt, jobs: Schema.Array(Job) });
+
+export function actionsCiStatus(
+ status: string,
+ conclusion: string | null | undefined,
+): PullRequestCheckStatus {
+ if (status === "action_required" || conclusion === "action_required") return "action-required";
+ if (status !== "completed") return "pending";
+ switch (conclusion) {
+ case "success":
+ return "success";
+ case "failure":
+ case "timed_out":
+ case "startup_failure":
+ return "failure";
+ case "cancelled":
+ return "cancelled";
+ case "skipped":
+ return "skipped";
+ default:
+ return "neutral";
+ }
+}
+
+export function resolveCheckUrl(raw: string | null | undefined, baseUrl: string): string | null {
+ if (!raw?.trim()) return null;
+ try {
+ const url = new URL(raw.trim(), `${baseUrl.replace(/\/+$/, "")}/`);
+ return ["https:", "http:"].includes(url.protocol) && !url.username && !url.password
+ ? url.href
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+/** GitHub and Gitea expose the same Actions read format, with different job rerun routes. */
+export function makeActionsCi(options: {
+ readonly request: (
+ input: ProviderRepositoryRef & {
+ readonly method: "GET" | "POST";
+ readonly path: string;
+ },
+ ) => Effect.Effect<{ readonly body: string; readonly truncated: boolean }, E>;
+ readonly fail: (detail: string) => E;
+ readonly kind: "github" | "gitea";
+ readonly baseUrl?: string;
+}): ProviderCiApi {
+ const fail = (detail: string) => Effect.fail(options.fail(detail));
+ const repoPath = (input: PullRef) =>
+ `/repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`;
+ const read = (input: PullRef, path: string, schema: Schema.Codec ) =>
+ options
+ .request({ ...input, path, method: "GET" })
+ .pipe(
+ Effect.flatMap((response) =>
+ response.truncated
+ ? fail("The CI response was too large.")
+ : Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(response.body).pipe(
+ Effect.mapError(() => options.fail("The host returned invalid CI data.")),
+ ),
+ ),
+ );
+ const pull = (input: PullRef) => read(input, `${repoPath(input)}/pulls/${input.number}`, Pull);
+ const canWrite = (input: PullRef) =>
+ read(input, repoPath(input), Repository).pipe(
+ Effect.map((repo) => repo.permissions?.push === true || repo.permissions?.admin === true),
+ );
+ const matches = (run: typeof Run.Type, pr: typeof Pull.Type, number: number) => {
+ const related = run.pull_requests ?? [];
+ if (related.length > 0 && !related.some((entry) => entry.number === number)) return false;
+ return (
+ run.head_sha === pr.head.sha ||
+ (related.some((entry) => entry.number === number) && run.head_sha === pr.merge_commit_sha)
+ );
+ };
+ const modes = (run: typeof Run.Type, writable: boolean): PullRequestCiRun["rerunModes"] =>
+ !writable || run.status !== "completed" || run.conclusion === "action_required"
+ ? []
+ : run.conclusion === "failure" ||
+ run.conclusion === "cancelled" ||
+ run.conclusion === "timed_out"
+ ? ["all", "failed"]
+ : ["all"];
+ const jobMatchesRun = (job: typeof Job.Type, run: typeof Run.Type) =>
+ job.run_id === run.id &&
+ // Legacy Gitea runs have no attempt counter; their jobs count attempts separately.
+ ((run.run_attempt ?? 0) === 0 ||
+ job.run_attempt === undefined ||
+ job.run_attempt === run.run_attempt);
+ const currentRun = Effect.fn("ActionsCi.currentRun")(function* (input: RunRef) {
+ if (!/^[1-9]\d*$/.test(input.runId)) return yield* fail("Invalid CI run ID.");
+ const pr = yield* pull(input);
+ if (pr.head.sha !== input.headSha)
+ return yield* fail("The pull request changed. Refresh CI runs and try again.");
+ const run = yield* read(input, `${repoPath(input)}/actions/runs/${input.runId}`, Run);
+ if (String(run.id) !== input.runId || !matches(run, pr, input.number))
+ return yield* fail("This CI run does not belong to the current pull request revision.");
+ if ((run.run_attempt ?? 0) !== input.attempt)
+ return yield* fail("This CI run has a newer attempt. Refresh CI runs and try again.");
+ return run;
+ });
+ const getCiRuns: ProviderCiApi["getCiRuns"] = Effect.fn("ActionsCi.getCiRuns")(
+ function* (input) {
+ const pr = yield* pull(input);
+ const writable = yield* canWrite(input);
+ const shas = [
+ ...new Set([pr.head.sha, pr.merge_commit_sha].filter((sha): sha is string => Boolean(sha))),
+ ];
+ const pages = yield* Effect.all(
+ shas.map((sha) =>
+ read(
+ input,
+ `${repoPath(input)}/actions/runs?head_sha=${encodeURIComponent(sha)}&per_page=50&limit=50`,
+ Runs,
+ ),
+ ),
+ { concurrency: 2 },
+ );
+ const runs = new Map();
+ for (const page of pages)
+ for (const run of page.workflow_runs)
+ if (matches(run, pr, input.number)) runs.set(run.id, run);
+ return {
+ headSha: pr.head.sha,
+ truncated: pages.some((page) => page.total_count > page.workflow_runs.length),
+ runs: [...runs.values()]
+ .sort((a, b) => b.id - a.id)
+ .map((run) => ({
+ id: String(run.id),
+ name:
+ run.name?.trim() || run.path?.split("@")[0] || run.display_title || `Run ${run.id}`,
+ url: resolveCheckUrl(run.html_url, options.baseUrl ?? `https://${input.host}`),
+ status: actionsCiStatus(run.status, run.conclusion),
+ attempt: run.run_attempt ?? 0,
+ rerunModes: modes(run, writable),
+ })),
+ };
+ },
+ );
+ const getCiJobs: ProviderCiApi["getCiJobs"] = Effect.fn("ActionsCi.getCiJobs")(
+ function* (input) {
+ const run = yield* currentRun(input);
+ const writable = yield* canWrite(input);
+ const jobs: Array = [];
+ let total = 0;
+ for (let page = 1; page <= 10; page += 1) {
+ const result = yield* read(
+ input,
+ `${repoPath(input)}/actions/runs/${input.runId}/jobs?filter=latest&per_page=50&limit=50&page=${page}`,
+ Jobs,
+ );
+ total = result.total_count;
+ jobs.push(...result.jobs);
+ if (jobs.length >= total || result.jobs.length === 0) break;
+ }
+ return {
+ truncated: total > jobs.length,
+ jobs: jobs.map((job) => ({
+ id: String(job.id),
+ name: job.name,
+ url: resolveCheckUrl(job.html_url, options.baseUrl ?? `https://${input.host}`),
+ status: actionsCiStatus(job.status, job.conclusion),
+ canRerun:
+ modes(run, writable).length > 0 &&
+ job.status === "completed" &&
+ jobMatchesRun(job, run),
+ })),
+ };
+ },
+ );
+ const rerunCi: ProviderCiApi["rerunCi"] = Effect.fn("ActionsCi.rerunCi")(function* (input) {
+ const run = yield* currentRun(input);
+ const writable = yield* canWrite(input);
+ const available = modes(run, writable);
+ if (available.length === 0)
+ return yield* fail("Rerunning CI requires write access and a completed run.");
+ const root = `${repoPath(input)}/actions`;
+ let path: string;
+ if (input.target.kind === "job") {
+ if (!/^[1-9]\d*$/.test(input.target.jobId)) return yield* fail("Invalid CI job ID.");
+ const job = yield* read(input, `${root}/jobs/${input.target.jobId}`, Job);
+ if (
+ String(job.id) !== input.target.jobId ||
+ job.status !== "completed" ||
+ !jobMatchesRun(job, run)
+ )
+ return yield* fail("This job is not a completed job in the selected CI attempt.");
+ path =
+ options.kind === "github"
+ ? `${root}/jobs/${job.id}/rerun`
+ : `${root}/runs/${run.id}/jobs/${job.id}/rerun`;
+ } else {
+ if (!available.includes(input.target.kind))
+ return yield* fail("This run has no failed jobs to rerun.");
+ path = `${root}/runs/${run.id}/${input.target.kind === "all" ? "rerun" : "rerun-failed-jobs"}`;
+ }
+ yield* options.request({ ...input, path, method: "POST" });
+ });
+ return { getCiRuns, getCiJobs, rerunCi };
+}
diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts
index 8f2992086926..19bccc89a369 100644
--- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts
+++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts
@@ -193,6 +193,41 @@ afterEach(() => {
});
layer("GitHubPullRequestCli.layer", (it) => {
+ it.effect("addresses CI reads to the selected GitHub Enterprise host", () =>
+ Effect.gen(function* () {
+ mockedExecute.mockImplementation((input) => {
+ expect(input.args.slice(0, 5)).toEqual([
+ "api",
+ "--hostname",
+ "github.enterprise.test",
+ "--method",
+ "GET",
+ ]);
+ const path = input.args.at(-1)!;
+ return Effect.succeed(
+ output(
+ JSON.stringify(
+ path.endsWith("/pulls/1")
+ ? { head: { sha: "head" } }
+ : path === "/repos/acme/web"
+ ? { permissions: { push: true } }
+ : { total_count: 0, workflow_runs: [] },
+ ),
+ ),
+ );
+ });
+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
+ expect(
+ (yield* cli.getCiRuns({
+ cwd: "/workspace",
+ host: "github.enterprise.test",
+ repository: "acme/web",
+ number: 1,
+ })).runs,
+ ).toEqual([]);
+ }),
+ );
+
it.effect("pages saved viewed files and resets dismissed file state", () =>
Effect.gen(function* () {
const page = (nodes: unknown[], cursor: string | null) =>
diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts
index e65e8b3ada96..3523baea215c 100644
--- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts
+++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts
@@ -27,6 +27,8 @@ import {
} from "@t3tools/contracts";
import * as GitHubCli from "../sourceControl/GitHubCli.ts";
+import { makeActionsCi } from "./ActionsCi.ts";
+import type { ProviderCiApi } from "./PullRequestProvider.ts";
import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
import {
@@ -339,8 +341,13 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError()("GitHubCiError", {
+ detail: Schema.String,
+}) {}
+
export type GitHubPullRequestCliError =
| GitHubStackActionError
+ | GitHubCiError
| GitHubCli.GitHubCliError
| GitHubPullRequestReadError
| GitHubDiffCursorError
@@ -734,7 +741,7 @@ export class GitHubPullRequestCli extends Context.Service<
readonly kind: "issue-comment" | "review-comment";
readonly body: string;
}) => Effect.Effect;
- }
+ } & ProviderCiApi
>()("t3/pullRequest/GitHubPullRequestCli") {}
/**
@@ -1523,6 +1530,19 @@ export const make = Effect.gen(function* () {
);
return GitHubPullRequestCli.of({
+ ...makeActionsCi({
+ kind: "github",
+ request: (input) =>
+ github
+ .execute({
+ cwd: input.cwd,
+ args: ["api", "--hostname", input.host, "--method", input.method, input.path],
+ })
+ .pipe(
+ Effect.map((result) => ({ body: result.stdout, truncated: result.stdoutTruncated })),
+ ),
+ fail: (detail) => new GitHubCiError({ detail }),
+ }),
getNativeDependencyMembership: makeGitHubNativeStackRead(github.execute),
getViewerLogin: (input) =>
github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe(
diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts
index ff3923e09d41..d8caf3acd625 100644
--- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts
+++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts
@@ -21,6 +21,7 @@ import {
import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts";
const CAPABILITIES: PullRequestCapabilities = {
+ ciRuns: true,
diff: true,
fileViewedState: true,
comment: true,
@@ -213,6 +214,9 @@ export const make = Effect.gen(function* () {
const provider: PullRequestProviderApi = {
kind: "github",
capabilities: CAPABILITIES,
+ getCiRuns: (input) => cli.getCiRuns(input).pipe(Effect.mapError(fail("getCiRuns"))),
+ getCiJobs: (input) => cli.getCiJobs(input).pipe(Effect.mapError(fail("getCiJobs"))),
+ rerunCi: (input) => cli.rerunCi(input).pipe(Effect.mapError(fail("rerunCi"))),
getNativeDependencyMembership: (input) => cli.getNativeDependencyMembership(input),
diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts
index 1e590d72520b..49955b555a71 100644
--- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts
+++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts
@@ -145,6 +145,78 @@ it.effect("keeps a search hydration transport failure fatal", () =>
);
layer("GiteaPullRequestApi", (it) => {
+ it.effect("normalizes CI status links using the configured web root", () =>
+ Effect.gen(function* () {
+ mockedRequest.mockReturnValue(
+ Effect.succeed(
+ response({
+ statuses: [
+ {
+ context: "CI",
+ status: "failure",
+ target_url: "/gitea/acme/web/actions/runs/42/jobs/9",
+ },
+ {
+ context: "External",
+ status: "success",
+ target_url: "https://ci.example.test/build/1",
+ },
+ ],
+ }),
+ ),
+ );
+ const api = yield* GiteaPullRequestApi.make;
+ const checks = yield* api.listChecks({
+ host: "forge.example.test",
+ repository: "acme/web",
+ sha: "head",
+ });
+ expect(checks.map((check) => check.url)).toEqual([
+ "https://forge.example.test/gitea/acme/web/actions/runs/42/jobs/9",
+ "https://ci.example.test/build/1",
+ ]);
+ }),
+ );
+
+ it.effect("routes CI reads through configured Gitea auth and rejects another host", () =>
+ Effect.gen(function* () {
+ mockedRequest.mockImplementation((input) =>
+ Effect.succeed(
+ response(
+ input.path.endsWith("/pulls/1")
+ ? { head: { sha: "head" } }
+ : input.path === "/repos/acme/web"
+ ? { permissions: { push: true } }
+ : {
+ total_count: 1,
+ workflow_runs: [
+ {
+ id: 12,
+ head_sha: "head",
+ html_url: "acme/web/actions/runs/12",
+ status: "queued",
+ },
+ ],
+ },
+ ),
+ ),
+ );
+ const api = yield* GiteaPullRequestApi.make;
+ const input = {
+ cwd: "/workspace",
+ host: "forge.example.test",
+ repository: "acme/web",
+ number: 1,
+ };
+ expect((yield* api.getCiRuns(input)).runs[0]?.url).toBe(
+ "https://forge.example.test/gitea/acme/web/actions/runs/12",
+ );
+ const calls = mockedRequest.mock.calls.length;
+ yield* api.getCiRuns({ ...input, host: "another.example.test" }).pipe(Effect.flip);
+ expect(mockedRequest).toHaveBeenCalledTimes(calls);
+ }),
+ );
+
it.effect("preserves tracking rows while keeping dependency reads lightweight", () =>
Effect.gen(function* () {
mockedRequest.mockImplementation(() =>
diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts
index 8d38ed9bd05a..ef79ae1360da 100644
--- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts
+++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts
@@ -34,6 +34,8 @@ import type {
} from "@t3tools/contracts";
import * as GiteaApi from "../sourceControl/GiteaApi.ts";
+import { makeActionsCi, resolveCheckUrl } from "./ActionsCi.ts";
+import type { ProviderCiApi } from "./PullRequestProvider.ts";
import * as GiteaLifecycle from "./GiteaLifecycle.ts";
import * as GiteaWorkflows from "./GiteaWorkflows.ts";
import {
@@ -691,7 +693,7 @@ export class GiteaPullRequestApi extends Context.Service<
content: PullRequestReactionContent;
reacted: boolean;
}) => Effect.Effect;
- }
+ } & ProviderCiApi
>()("t3/pullRequest/GiteaPullRequestApi") {}
export const make = Effect.gen(function* () {
@@ -1647,7 +1649,10 @@ export const make = Effect.gen(function* () {
? "skipped"
: "neutral",
description: status.description?.trim() || null,
- url: status.target_url?.trim() || null,
+ url: resolveCheckUrl(
+ status.target_url,
+ Option.getOrElse(gitea.baseUrl, () => `https://${input.host}`),
+ ),
},
},
];
@@ -1911,6 +1916,12 @@ export const make = Effect.gen(function* () {
});
return GiteaPullRequestApi.of({
+ ...makeActionsCi({
+ kind: "gitea",
+ ...(Option.isSome(gitea.baseUrl) ? { baseUrl: gitea.baseUrl.value } : {}),
+ request: (input) => request({ ...input, operation: "ci" }),
+ fail: (detail) => new GiteaPullRequestApiError({ operation: "ci", reason: "failed", detail }),
+ }),
getFeatures: () => getFeatures,
getWorkflowApprovals,
getViewer: Effect.fn("GiteaPullRequestApi.getViewer")(function* () {
diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts
index 5c0c10cc1154..35e9c445ef6b 100644
--- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts
+++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts
@@ -13,6 +13,7 @@ import {
} from "./PullRequestProvider.ts";
const CAPABILITIES: PullRequestCapabilities = {
+ ciRuns: true,
diff: true,
comment: true,
actions: [
@@ -156,6 +157,9 @@ export const make = Effect.gen(function* () {
const provider: PullRequestProviderApi = {
kind: "gitea",
capabilities: CAPABILITIES,
+ getCiRuns: (input) => api.getCiRuns(input).pipe(Effect.mapError(fail("getCiRuns"))),
+ getCiJobs: (input) => api.getCiJobs(input).pipe(Effect.mapError(fail("getCiJobs"))),
+ rerunCi: (input) => api.rerunCi(input).pipe(Effect.mapError(fail("rerunCi"))),
getCapabilities: () =>
api.getFeatures().pipe(
Effect.orElseSucceed(() => []),
diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts
index 72e3ce4b0e8b..f0a598c705ee 100644
--- a/apps/server/src/pullRequest/PullRequestProvider.ts
+++ b/apps/server/src/pullRequest/PullRequestProvider.ts
@@ -2,6 +2,10 @@ import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import type {
PullRequestStackMembership,
+ PullRequestCiRuns,
+ PullRequestCiJobs,
+ PullRequestCiRunInput,
+ PullRequestCiRerunInput,
PullRequestAction,
PullRequestStackHead,
PullRequestActor,
@@ -303,7 +307,19 @@ export interface ProviderRepositoryRef {
* the neutral types above; anything a host cannot do is declared in `capabilities` rather than
* failing at call time.
*/
-export interface PullRequestProviderApi {
+export interface ProviderCiApi {
+ readonly getCiRuns: (
+ input: ProviderRepositoryRef & { readonly number: number },
+ ) => Effect.Effect;
+ readonly getCiJobs: (
+ input: ProviderRepositoryRef & Omit,
+ ) => Effect.Effect;
+ readonly rerunCi: (
+ input: ProviderRepositoryRef & Omit,
+ ) => Effect.Effect;
+}
+
+export interface PullRequestProviderApi extends Partial> {
readonly kind: SourceControlProviderKind;
readonly capabilities: PullRequestCapabilities;
/** Host-discovered additions, read only when a caller needs to gate a capability. */
diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts
index 531e2c045f60..17a020c68c63 100644
--- a/apps/server/src/pullRequest/PullRequestService.test.ts
+++ b/apps/server/src/pullRequest/PullRequestService.test.ts
@@ -5280,6 +5280,84 @@ it.effect("keeps Azure continuation cursors separate for repositories with the s
}),
);
+it.effect("reruns CI through the selected host and refreshes the PR for other clients", () =>
+ Effect.gen(function* () {
+ let attempt = 1;
+ const provider = fakeProvider("gitea", {
+ getChangeRequest: () => Effect.succeed(hostedChangeRequest(`attempt ${attempt}`)),
+ getCiRuns: () => Effect.succeed({ headSha: "head", runs: [], truncated: false }),
+ getCiJobs: () => Effect.succeed({ jobs: [], truncated: false }),
+ rerunCi: (input) =>
+ Effect.sync(() => {
+ assert.strictEqual(input.host, "forge.test");
+ assert.strictEqual(input.repository, "acme/web");
+ assert.strictEqual(input.cwd, "/workspace");
+ attempt += 1;
+ }),
+ });
+ const service = yield* makeService({
+ projects: [
+ project({
+ id: "p1",
+ title: "Web",
+ workspaceRoot: "/workspace",
+ repository: "acme/web",
+ provider: "gitea",
+ host: "forge.test",
+ }),
+ ],
+ providers: [{ ...provider, capabilities: { ...provider.capabilities, ciRuns: true } }],
+ });
+ const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
+ assert.strictEqual((yield* service.detail(reference)).body, "attempt 1");
+ yield* service.rerunCi({
+ ...reference,
+ headSha: "head",
+ runId: "12",
+ attempt: 1,
+ target: { kind: "failed" },
+ });
+ assert.strictEqual((yield* service.detail(reference)).body, "attempt 2");
+ const refresh = yield* service.subscribeRefreshes.pipe(Stream.take(1), Stream.runHead);
+ assert.deepEqual(Option.getOrThrow(refresh).reference, reference);
+ assert.isTrue(Option.getOrThrow(refresh).listings);
+ }),
+);
+
+it.effect("does not call CI adapters without an advertised capability", () =>
+ Effect.gen(function* () {
+ const service = yield* makeService({
+ projects: [
+ project({ id: "p1", title: "Web", workspaceRoot: "/workspace", repository: "acme/web" }),
+ ],
+ providers: [
+ fakeProvider("github", {
+ getCiRuns: () => Effect.die("unsupported read"),
+ getCiJobs: () => Effect.die("unsupported read"),
+ rerunCi: () => Effect.die("unsupported write"),
+ }),
+ ],
+ });
+ const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
+ assert.strictEqual(
+ (yield* service.ciRuns(reference).pipe(Effect.flip))._tag,
+ "PullRequestOperationError",
+ );
+ assert.strictEqual(
+ (yield* service
+ .rerunCi({
+ ...reference,
+ headSha: "head",
+ runId: "12",
+ attempt: 1,
+ target: { kind: "all" },
+ })
+ .pipe(Effect.flip))._tag,
+ "PullRequestOperationError",
+ );
+ }),
+);
+
it.effect("saves viewed progress through the selected host and refreshes other clients", () =>
Effect.gen(function* () {
const viewed = { headSha: "head", files: [{ path: "src/a.ts", viewed: false }] };
diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts
index 1c1c02f35ed3..7b7528f7abe2 100644
--- a/apps/server/src/pullRequest/PullRequestService.ts
+++ b/apps/server/src/pullRequest/PullRequestService.ts
@@ -25,6 +25,10 @@ import {
type OrchestrationProjectShell,
type PullRequestAction,
type PullRequestActionInput,
+ type PullRequestCiRuns,
+ type PullRequestCiJobs,
+ type PullRequestCiRunInput,
+ type PullRequestCiRerunInput,
type PullRequestActivity,
type PullRequestCommentInput,
type PullRequestCommentUpdateInput,
@@ -180,6 +184,11 @@ export class PullRequestService extends Context.Service<
readonly subscribeRefreshes: Stream.Stream;
readonly refreshAfterTurn: Effect.Effect;
readonly detail: (input: PullRequestRef) => Effect.Effect;
+ readonly ciRuns: (input: PullRequestRef) => Effect.Effect;
+ readonly ciJobs: (
+ input: PullRequestCiRunInput,
+ ) => Effect.Effect;
+ readonly rerunCi: (input: PullRequestCiRerunInput) => Effect.Effect;
readonly dependencyContext: (
input: PullRequestRef,
) => Effect.Effect;
@@ -510,6 +519,9 @@ function withRateLimitBackoff(
listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats),
}),
getChangeRequest: wrap("getChangeRequest", api.getChangeRequest),
+ ...(api.getCiRuns === undefined ? {} : { getCiRuns: wrap("getCiRuns", api.getCiRuns) }),
+ ...(api.getCiJobs === undefined ? {} : { getCiJobs: wrap("getCiJobs", api.getCiJobs) }),
+ ...(api.rerunCi === undefined ? {} : { rerunCi: interactive("rerunCi", api.rerunCi) }),
...(api.getNativeDependencyMembership === undefined
? {}
: {
@@ -1754,6 +1766,53 @@ export const make = Effect.gen(function* () {
),
);
+ const ciProvider = Effect.fn("PullRequestService.ciProvider")(function* (input: PullRequestRef) {
+ const project = yield* requireProject(input);
+ const capabilities = yield* capabilitiesOf(project);
+ const { getCiRuns, getCiJobs, rerunCi } = project.api;
+ if (!capabilities.ciRuns || !getCiRuns || !getCiJobs || !rerunCi) {
+ return yield* new PullRequestOperationError({
+ operation: "ci",
+ detail: "This host does not support inline CI runs.",
+ });
+ }
+ return {
+ getCiRuns,
+ getCiJobs,
+ rerunCi,
+ reference: {
+ cwd: project.project.workspaceRoot,
+ host: project.host,
+ repository: project.repository,
+ number: input.number,
+ },
+ };
+ });
+ const ciRuns: PullRequestService["Service"]["ciRuns"] = Effect.fn("PullRequestService.ciRuns")(
+ function* (input) {
+ const provider = yield* ciProvider(input);
+ return yield* provider
+ .getCiRuns(provider.reference)
+ .pipe(Effect.mapError(toPullRequestError("ciRuns")));
+ },
+ );
+ const ciJobs: PullRequestService["Service"]["ciJobs"] = Effect.fn("PullRequestService.ciJobs")(
+ function* (input) {
+ const provider = yield* ciProvider(input);
+ return yield* provider
+ .getCiJobs({ ...input, ...provider.reference })
+ .pipe(Effect.mapError(toPullRequestError("ciJobs")));
+ },
+ );
+ const rerunCi: PullRequestService["Service"]["rerunCi"] = Effect.fn("PullRequestService.rerunCi")(
+ function* (input) {
+ const provider = yield* ciProvider(input);
+ yield* provider
+ .rerunCi({ ...input, ...provider.reference })
+ .pipe(Effect.mapError(toPullRequestError("rerunCi")));
+ },
+ );
+
const viewedFiles: PullRequestService["Service"]["viewedFiles"] = Effect.fn(
"PullRequestService.viewedFiles",
)(function* (input) {
@@ -3209,6 +3268,9 @@ export const make = Effect.gen(function* () {
diff,
viewedFiles,
setFileViewed: invalidatedByMutation(setFileViewed, false),
+ ciRuns,
+ ciJobs,
+ rerunCi: invalidatedByMutation(rerunCi),
diffFileContents,
runAction: runActionAndInvalidate,
update: invalidatedByMutation(update),
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 2e4415f1f006..85d1ba8b6fbf 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -2170,6 +2170,18 @@ const makeWsRpcLayer = (
),
{ "rpc.aggregate": "pull-requests" },
),
+ [WS_METHODS.pullRequestsCiRuns]: (input) =>
+ observeRpcEffect(WS_METHODS.pullRequestsCiRuns, pullRequests.ciRuns(input), {
+ projectId: input.projectId,
+ }),
+ [WS_METHODS.pullRequestsCiJobs]: (input) =>
+ observeRpcEffect(WS_METHODS.pullRequestsCiJobs, pullRequests.ciJobs(input), {
+ projectId: input.projectId,
+ }),
+ [WS_METHODS.pullRequestsRerunCi]: (input) =>
+ observeRpcEffect(WS_METHODS.pullRequestsRerunCi, pullRequests.rerunCi(input), {
+ projectId: input.projectId,
+ }),
[WS_METHODS.pullRequestsDetail]: (input) =>
observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), {
"rpc.aggregate": "pull-requests",
diff --git a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx
index 20100339ecbb..3b00b972e450 100644
--- a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx
@@ -6,6 +6,9 @@ import type {
ScopedThreadRef,
} from "@t3tools/contracts";
+import { CircleDotIcon } from "lucide-react";
+import { PullRequestCiRuns } from "./PullRequestCiRuns";
+
import { useOpenLink } from "~/browser/useOpenLink";
import { cn } from "~/lib/utils";
import { pullRequestEnvironment } from "~/state/pullRequests";
@@ -48,7 +51,18 @@ function LazyChecksBody({
);
}
- return ;
+ return (
+ <>
+
+ {detailQuery.data.capabilities.ciRuns && (
+
+ )}
+ >
+ );
}
function ChecksBody({
@@ -103,18 +117,20 @@ function ChecksBody({
* The checks indicator and what it opens, in both places a change request is shown: a listing
* row, which knows only the rollup, and the detail header, which is already holding every check.
*
- * `checks` decides between the two. Given them, nothing is read; without them, the popup reads
+ * `checks` decides between the two. Given them, the detail read is skipped; without them, the popup reads
* the detail itself, which is why the row must also say which environment it came from.
*/
export function PullRequestChecksPopover({
checksState,
+ ciRuns = false,
checks,
environmentId,
reference,
threadRef = null,
className,
}: {
- checksState: PullRequestChecksState;
+ checksState?: PullRequestChecksState | null;
+ ciRuns?: boolean;
/** The checks already in hand, for the detail header. Absent on a listing row. */
checks?: ReadonlyArray;
environmentId?: EnvironmentId;
@@ -123,7 +139,10 @@ export function PullRequestChecksPopover({
threadRef?: ScopedThreadRef | null;
className?: string;
}) {
- const presentation = pullRequestChecksStatePresentation(checksState);
+ const presentation =
+ checksState == null
+ ? { label: "Checks", Icon: CircleDotIcon, toneClassName: "text-muted-foreground" }
+ : pullRequestChecksStatePresentation(checksState);
// Counts beat the rollup's own wording where they are known, the way GitHub's own header reads.
const summary = checks === undefined ? null : summarizePullRequestChecks(checks);
return (
@@ -145,11 +164,24 @@ export function PullRequestChecksPopover({
>
-
+
{presentation.label}
{summary === null ? null : {summary}
}
{checks !== undefined ? (
-
+ <>
+
+ {ciRuns && environmentId && reference && (
+
+ )}
+ >
) : environmentId !== undefined && reference !== undefined ? (
("idle");
+let registry: AtomRegistry.AtomRegistry;
+
+const mocks = vi.hoisted(() => ({
+ rerun: vi.fn(),
+ invalidate: vi.fn(),
+ jobs: vi.fn(),
+ toast: vi.fn(),
+ open: vi.fn(),
+ pending: false,
+}));
+vi.mock("~/state/pullRequests", () => ({
+ pullRequestEnvironment: {
+ ciRuns: () => "runs",
+ ciJobs: (input: unknown) => {
+ mocks.jobs(input);
+ return "jobs";
+ },
+ rerunCi: "rerun",
+ ciRerunState: () => submission,
+ invalidate: "invalidate",
+ },
+}));
+vi.mock("@effect/atom-react", () => ({
+ useAtomValue: (atom: Atom.Atom) =>
+ useSyncExternalStore(
+ (notify) => registry.subscribe(atom, notify),
+ () => registry.get(atom),
+ ),
+}));
+vi.mock("~/state/use-atom-command", () => ({
+ useAtomCommand: (command: string) => (command === "rerun" ? mocks.rerun : mocks.invalidate),
+}));
+vi.mock("~/state/query", () => ({
+ useEnvironmentQuery: (atom: string) => ({
+ error: null,
+ isPending: mocks.pending,
+ refresh: vi.fn(),
+ data:
+ atom === "runs"
+ ? {
+ headSha: "head",
+ truncated: false,
+ runs: [
+ {
+ id: "12",
+ name: "CI",
+ url: "https://forge.test/run/12",
+ status: "failure",
+ attempt: 2,
+ rerunModes: ["all", "failed"],
+ },
+ ],
+ }
+ : {
+ jobs: [
+ {
+ id: "93",
+ name: "Tests",
+ url: "https://forge.test/jobs/999",
+ status: "failure",
+ canRerun: true,
+ },
+ ],
+ truncated: false,
+ },
+ }),
+}));
+vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => mocks.open }));
+vi.mock("../ui/toast", () => ({ toastManager: { add: mocks.toast } }));
+vi.mock("../ui/button", () => ({ Button: (props: object) => createElement("button", props) }));
+
+import { PullRequestCiRuns } from "./PullRequestCiRuns";
+
+const props = {
+ environmentId: EnvironmentId.make("environment"),
+ reference: { projectId: ProjectId.make("project"), repository: "acme/web", number: 42 },
+};
+let renderer: ReactTestRenderer;
+const button = (label: string) =>
+ renderer.root.findAllByType("button").find((node) => node.props.children === label)!;
+
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ mocks.pending = false;
+ registry = AtomRegistry.make();
+ mocks.open.mockResolvedValue(undefined);
+});
+afterEach(async () => {
+ if (renderer) await act(async () => renderer.unmount());
+ registry.dispose();
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+});
+
+it("loads jobs only after expanding a run, and opens their host details", async () => {
+ await act(async () => {
+ renderer = create( );
+ });
+ expect(mocks.jobs).not.toHaveBeenCalled();
+ await act(async () => button("CI").props.onClick());
+ expect(mocks.jobs).toHaveBeenCalled();
+ await act(async () => button("Tests").props.onClick());
+ expect(mocks.open).toHaveBeenCalledWith("https://forge.test/jobs/999");
+});
+
+it("shows shared rerun progress and allows another request after CI refreshes", async () => {
+ mocks.rerun.mockImplementation(async () => {
+ registry.set(submission, "pending");
+ return { _tag: "Success", value: undefined };
+ });
+ await act(async () => {
+ renderer = create( );
+ });
+ await act(async () => {
+ button("Rerun failed").props.onClick();
+ });
+ expect(mocks.rerun).toHaveBeenCalledTimes(1);
+ expect(button("Rerun all").props.disabled).toBe(true);
+ await act(async () => registry.set(submission, "requested"));
+ expect(
+ renderer.root
+ .findAllByProps({ role: "status" })
+ .some((node) => node.children.includes("Rerun requested")),
+ ).toBe(true);
+ expect(button("Rerun failed").props.disabled).toBe(true);
+ await act(async () => registry.set(submission, "idle"));
+ await act(async () => button("Rerun all").props.onClick());
+ expect(mocks.rerun).toHaveBeenCalledTimes(2);
+});
+
+it("does not submit a rerun while the run data is refreshing", async () => {
+ mocks.pending = true;
+ await act(async () => {
+ renderer = create( );
+ });
+ await act(async () => button("Rerun all").props.onClick());
+ expect(mocks.rerun).not.toHaveBeenCalled();
+ expect(button("Rerun all").props.disabled).toBe(true);
+});
diff --git a/apps/web/src/components/pullRequest/PullRequestCiRuns.tsx b/apps/web/src/components/pullRequest/PullRequestCiRuns.tsx
new file mode 100644
index 000000000000..9f7bff7e59b2
--- /dev/null
+++ b/apps/web/src/components/pullRequest/PullRequestCiRuns.tsx
@@ -0,0 +1,237 @@
+import { useAtomValue } from "@effect/atom-react";
+import type {
+ EnvironmentId,
+ PullRequestCiRun,
+ PullRequestCiRerunTarget,
+ PullRequestCiRunInput,
+ PullRequestRef,
+ ScopedThreadRef,
+} from "@t3tools/contracts";
+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
+import { useState } from "react";
+
+import { useOpenLink } from "~/browser/useOpenLink";
+import { pullRequestEnvironment } from "~/state/pullRequests";
+import { useEnvironmentQuery } from "~/state/query";
+import { useAtomCommand } from "~/state/use-atom-command";
+
+import { Button } from "../ui/button";
+import { toastManager } from "../ui/toast";
+import { readableFailure } from "./pullRequestDetail.logic";
+import { PullRequestCheckStatusIcon, pullRequestCheckStatusLabel } from "./pullRequestPresentation";
+
+function CiJobs({
+ environmentId,
+ input,
+ disabled,
+ onRerun,
+ openLink,
+}: {
+ environmentId: EnvironmentId;
+ input: PullRequestCiRunInput;
+ disabled: boolean;
+ onRerun: (target: PullRequestCiRerunTarget) => void;
+ openLink: (url: string) => void;
+}) {
+ const query = useEnvironmentQuery(pullRequestEnvironment.ciJobs({ environmentId, input }));
+ if (query.error)
+ return (
+
+ {query.error}
+
+ );
+ if (!query.data) return Loading jobs…
;
+ return (
+
+ {query.data.jobs.map((job) => (
+
+
+
job.url && openLink(job.url)}
+ >
+ {job.name}
+
+
{pullRequestCheckStatusLabel(job)}
+ {job.canRerun && (
+
onRerun({ kind: "job", jobId: job.id })}
+ >
+ Rerun
+
+ )}
+
+ ))}
+ {query.data.jobs.length === 0 && (
+
No jobs reported.
+ )}
+ {query.data.truncated && (
+
More jobs are available on the host.
+ )}
+
+ );
+}
+
+function CiRunRow({
+ environmentId,
+ reference,
+ headSha,
+ run,
+ openLink,
+ disabled,
+}: {
+ environmentId: EnvironmentId;
+ reference: PullRequestRef;
+ headSha: string;
+ run: PullRequestCiRun;
+ openLink: (url: string) => void;
+ disabled: boolean;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const rerun = useAtomCommand(pullRequestEnvironment.rerunCi, { reportFailure: false });
+ const input = { ...reference, runId: run.id, headSha, attempt: run.attempt };
+ const submission = useAtomValue(pullRequestEnvironment.ciRerunState({ environmentId, input }));
+ const pending = submission === "pending";
+ const requested = submission === "requested";
+ const submit = async (target: PullRequestCiRerunTarget) => {
+ if (pending || requested || disabled) return;
+ const result = await rerun({ environmentId, input: { ...input, target } });
+ if (result._tag === "Failure")
+ toastManager.add({
+ type: "error",
+ title: "Unable to rerun CI",
+ description: readableFailure(
+ squashAtomCommandFailure(result),
+ "The host refused the rerun.",
+ ),
+ });
+ };
+ return (
+
+
+
+
setExpanded(!expanded)}
+ >
+ {run.name}
+
+ {run.url && (
+
openLink(run.url!)}
+ >
+ Open
+
+ )}
+
+
+
+ {pending
+ ? "Requesting rerun…"
+ : requested
+ ? "Rerun requested"
+ : pullRequestCheckStatusLabel(run)}
+
+ {run.rerunModes.map((kind) => (
+ void submit({ kind })}
+ >
+ {kind === "failed" ? "Rerun failed" : "Rerun all"}
+
+ ))}
+
+ {expanded && (
+
void submit(target)}
+ openLink={openLink}
+ />
+ )}
+
+ );
+}
+
+/** Mounted only in an open checks popup or PR summary. */
+export function PullRequestCiRuns({
+ environmentId,
+ reference,
+ threadRef = null,
+}: {
+ environmentId: EnvironmentId;
+ reference: PullRequestRef;
+ threadRef?: ScopedThreadRef | null;
+}) {
+ const query = useEnvironmentQuery(
+ pullRequestEnvironment.ciRuns({ environmentId, input: reference }),
+ );
+ const invalidate = useAtomCommand(pullRequestEnvironment.invalidate);
+ const open = useOpenLink(threadRef);
+ const openLink = (url: string) => {
+ void open(url).catch(() =>
+ toastManager.add({ type: "error", title: "Unable to open CI details" }),
+ );
+ };
+ return (
+
+
+
CI runs
+ {
+ void invalidate({ environmentId, input: { reference } });
+ }}
+ >
+ Refresh CI
+
+
+ {query.error ? (
+
+ {query.error}
+
+ ) : null}
+ {query.data ? (
+ <>
+ {query.data.runs.map((run) => (
+
+ ))}
+ {query.data.runs.length === 0 && (
+ No CI runs for this revision.
+ )}
+ {query.data.truncated && (
+
+ Showing the most recent runs. More are available on the host.
+
+ )}
+ >
+ ) : query.isPending ? (
+ Loading CI runs…
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
index d0465b787c64..087b3978fd5c 100644
--- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
@@ -18,7 +18,6 @@ import {
ArrowLeftIcon,
ArrowUpRightIcon,
BookOpenIcon,
- CircleDotIcon,
ChevronDownIcon,
ExternalLinkIcon,
FileDiffIcon,
@@ -2467,15 +2466,14 @@ export function PullRequestDetailPanel({
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
aria-label={checksSummary ? `Checks: ${checksSummary}` : "Checks"}
>
- {checksState !== null ? (
-
- ) : (
-
- )}
+
{checksSummary}
)}
diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx
index 5f073684eabd..b48882a468e0 100644
--- a/apps/web/src/components/pullRequest/PullRequestRow.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx
@@ -151,17 +151,15 @@ function PullRequestRowImpl({
{entry.reviewDecision === "approved" ? "Approved" : "Changes requested"}
) : null}
- {entry.checksState === undefined ? null : (
-
- )}
+
{matchedElsewhere ? (
diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx
index 729c5a97e594..2cce2af0db0e 100644
--- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx
@@ -43,6 +43,7 @@ import {
pullRequestReviewOutcomeRingClassName,
pullRequestReviewOutcomeStaleLabel,
} from "./pullRequestPresentation";
+import { PullRequestCiRuns } from "./PullRequestCiRuns";
import { PullRequestLabelPicker } from "./PullRequestLabelPicker";
import { PullRequestReviewerPicker } from "./PullRequestReviewerPicker";
import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
@@ -787,6 +788,13 @@ export function PullRequestSummaryTab({
+ {detail.capabilities.ciRuns && (
+
+ )}
{detail.checks.length === 0 ? (
No checks reported.
) : (
diff --git a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx
index 6b832fd9ccc3..7f6780b49b98 100644
--- a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx
+++ b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx
@@ -121,8 +121,8 @@ describe("PullRequestRow checks indicator", () => {
).length;
}
- it("shows the indicator only for a row the host reported a rollup for", () => {
+ it("keeps checks accessible when the host omits its rollup", () => {
expect(indicators(row({ checksState: "failing" }))).toBe(1);
- expect(indicators(row({}))).toBe(0);
+ expect(indicators(row({}))).toBe(1);
});
});
diff --git a/docs/user/source-control.md b/docs/user/source-control.md
index 1a0d0875339d..c20c29afc299 100644
--- a/docs/user/source-control.md
+++ b/docs/user/source-control.md
@@ -128,6 +128,12 @@ branch updates, close/reopen, draft/ready changes, auto-merge controls, comment
reactions. Workflow approval and revert PRs are available when your Gitea server advertises
support for them.
+In a pull request's checks, expand **CI runs** to see GitHub Actions or Gitea Actions runs and
+jobs for the current revision. You can open their details or rerun a completed run, its failed
+jobs, or a selected job when your account has write access. A job rerun may also run dependent
+jobs. Use **Refresh CI** to see subsequent progress. On mobile, open the thread's Git sheet and
+expand **CI runs and checks**.
+
## Troubleshooting
- **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket or Gitea,
diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts
index 7eccbc28e8e1..318c96a48d09 100644
--- a/packages/client-runtime/src/state/pullRequests.test.ts
+++ b/packages/client-runtime/src/state/pullRequests.test.ts
@@ -29,6 +29,7 @@ import type { RpcSession } from "../rpc/session.ts";
import {
createPullRequestEnvironmentAtoms,
createPullRequestStackAtomFamily,
+ createPullRequestCiEnvironmentAtoms,
} from "./pullRequests.ts";
import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";
import { executeAtomQuery } from "./runtime.ts";
@@ -90,7 +91,7 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro
const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>
Effect.sync(() => registry.dispose()),
);
- return { runtime, atoms, registry };
+ return { runtime, atoms, ciAtoms: createPullRequestCiEnvironmentAtoms(runtime), registry };
});
it.effect("keeps concurrent diff file reads on different hosts separate", () =>
@@ -138,6 +139,247 @@ it.effect("keeps concurrent diff file reads on different hosts separate", () =>
),
);
+it.effect(
+ "refreshes runs, expanded jobs, and mobile checks after another client requests a rerun",
+ () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const reference = {
+ projectId: ProjectId.make("project-1"),
+ host: "forge.test",
+ repository: "acme/web",
+ number: 1,
+ };
+ const events = yield* PubSub.unbounded();
+ const subscribed = Latch.makeUnsafe();
+ let subscriptions = 0;
+ let attempt = 1;
+ const client = {
+ [WS_METHODS.pullRequestsSubscribeRefreshes]: () =>
+ Stream.unwrap(
+ Effect.gen(function* () {
+ const subscription = yield* PubSub.subscribe(events);
+ subscriptions += 1;
+ subscribed.openUnsafe();
+ return Stream.fromSubscription(subscription);
+ }),
+ ),
+ [WS_METHODS.pullRequestsCiRuns]: () =>
+ Effect.sync(() => ({
+ headSha: "head",
+ truncated: false,
+ runs: [
+ {
+ id: "12",
+ name: "CI",
+ url: null,
+ status: attempt === 1 ? "failure" : "pending",
+ attempt,
+ rerunModes: [],
+ },
+ ],
+ })),
+ [WS_METHODS.pullRequestsCiJobs]: () =>
+ Effect.sync(() => ({
+ truncated: false,
+ jobs: [
+ {
+ id: "93",
+ name: "CI",
+ url: null,
+ status: attempt === 1 ? "failure" : "pending",
+ canRerun: false,
+ },
+ ],
+ })),
+ [WS_METHODS.pullRequestsDetail]: () =>
+ Effect.sync(() => ({
+ checks: [{ name: "CI", status: attempt === 1 ? "failure" : "pending", url: null }],
+ })),
+ } as unknown as WsRpcProtocolClient;
+ const { ciAtoms: atoms, registry } = yield* makeTestRuntime(client);
+ const runs = atoms.ciRuns({ environmentId: TARGET.environmentId, input: reference });
+ const jobs = atoms.ciJobs({
+ environmentId: TARGET.environmentId,
+ input: { ...reference, runId: "12", headSha: "head", attempt: 1 },
+ });
+ const detail = atoms.detail({ environmentId: TARGET.environmentId, input: reference });
+ const mounted: ReadonlyArray> = [runs, jobs, detail];
+ for (const atom of mounted) {
+ const unmount = registry.mount(atom);
+ yield* Effect.addFinalizer(() => Effect.sync(unmount));
+ }
+ const initial = yield* Effect.promise(() => executeAtomQuery(registry, runs));
+ expect(AsyncResult.isSuccess(initial)).toBe(true);
+ yield* AtomRegistry.getResult(registry, jobs);
+ yield* AtomRegistry.getResult(registry, detail);
+ yield* subscribed.await;
+ const refreshed = Latch.makeUnsafe();
+ const stop = registry.subscribe(runs, (result) => {
+ if (AsyncResult.isSuccess(result) && result.value.runs[0]?.attempt === 2)
+ refreshed.openUnsafe();
+ });
+ yield* Effect.addFinalizer(() => Effect.sync(stop));
+ const jobsRefreshed = Latch.makeUnsafe();
+ const detailRefreshed = Latch.makeUnsafe();
+ const stopJobs = registry.subscribe(jobs, (result) => {
+ if (AsyncResult.isSuccess(result) && result.value.jobs[0]?.status === "pending")
+ jobsRefreshed.openUnsafe();
+ });
+ const stopDetail = registry.subscribe(detail, (result) => {
+ if (AsyncResult.isSuccess(result) && result.value.checks[0]?.status === "pending")
+ detailRefreshed.openUnsafe();
+ });
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ stopJobs();
+ stopDetail();
+ }),
+ );
+ attempt = 2;
+ yield* PubSub.publish(events, { revision: 1, reference, listings: true });
+ yield* refreshed.await;
+ yield* jobsRefreshed.await;
+ yield* detailRefreshed.await;
+ expect(subscriptions).toBe(1);
+ expect((yield* AtomRegistry.getResult(registry, runs)).runs[0]?.status).toBe("pending");
+ }),
+ ),
+);
+
+for (const nextStatus of ["pending", "failure"] as const) {
+ it.effect(
+ `shares rerun submission state across views and clears it on a fresh ${nextStatus} response without an attempt change`,
+ () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const reference = {
+ projectId: ProjectId.make("project-1"),
+ host: "forge.test",
+ repository: "acme/web",
+ number: 1,
+ };
+ const target = {
+ environmentId: TARGET.environmentId,
+ input: { ...reference, runId: "12", headSha: "head", attempt: 0 },
+ };
+ const events = yield* PubSub.unbounded();
+ const subscribed = Latch.makeUnsafe();
+ const started = Latch.makeUnsafe();
+ const finish = Latch.makeUnsafe();
+ let writes = 0;
+ let status: "pending" | "failure" = "failure";
+ const client = {
+ [WS_METHODS.pullRequestsSubscribeRefreshes]: () =>
+ Stream.unwrap(
+ Effect.gen(function* () {
+ const subscription = yield* PubSub.subscribe(events);
+ subscribed.openUnsafe();
+ return Stream.fromSubscription(subscription);
+ }),
+ ),
+ [WS_METHODS.pullRequestsCiRuns]: () =>
+ Effect.sync(() => ({
+ headSha: "head",
+ truncated: false,
+ runs: [
+ {
+ id: "12",
+ name: "CI",
+ url: null,
+ status,
+ attempt: 0,
+ rerunModes: status === "failure" ? ["all", "failed"] : [],
+ },
+ ],
+ })),
+ [WS_METHODS.pullRequestsRerunCi]: () =>
+ Effect.gen(function* () {
+ writes += 1;
+ started.openUnsafe();
+ yield* finish.await;
+ }),
+ } as unknown as WsRpcProtocolClient;
+ const { atoms, registry } = yield* makeTestRuntime(client);
+ const runs = atoms.ciRuns({
+ environmentId: target.environmentId,
+ input: {
+ number: reference.number,
+ repository: reference.repository,
+ projectId: reference.projectId,
+ host: reference.host,
+ },
+ });
+ const state = atoms.ciRerunState(target);
+ const otherHost = atoms.ciRerunState({
+ ...target,
+ input: { ...target.input, host: "another.test" },
+ });
+ const { number, ...otherInput } = target.input;
+ const otherView = atoms.ciRerunState({ ...target, input: { number, ...otherInput } });
+ const unmountRuns = registry.mount(runs);
+ const unmountState = registry.mount(state);
+ const unmountOther = registry.mount(otherView);
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ unmountRuns();
+ unmountState();
+ unmountOther();
+ }),
+ );
+ yield* AtomRegistry.getResult(registry, runs);
+ yield* subscribed.await;
+ const first = atoms.rerunCi.run(registry, {
+ ...target,
+ input: { ...target.input, target: { kind: "all" } },
+ });
+ yield* started.await;
+ expect(registry.get(otherView)).toBe("pending");
+ expect(registry.get(otherHost)).toBe("idle");
+ yield* Effect.promise(() =>
+ atoms.rerunCi.run(registry, {
+ ...target,
+ input: { ...target.input, target: { kind: "failed" } },
+ }),
+ );
+ expect(writes).toBe(1);
+ const beforeRefresh = yield* AtomRegistry.getResult(registry, runs);
+ const refreshedWhilePending = Latch.makeUnsafe();
+ const stopPendingRefresh = registry.subscribe(runs, (result) => {
+ if (AsyncResult.isSuccess(result) && !result.waiting && result.value !== beforeRefresh)
+ refreshedWhilePending.openUnsafe();
+ });
+ yield* Effect.addFinalizer(() => Effect.sync(stopPendingRefresh));
+ yield* PubSub.publish(events, { revision: 1, reference, listings: true });
+ yield* refreshedWhilePending.await;
+ expect(registry.get(otherView)).toBe("pending");
+ finish.openUnsafe();
+ const result = yield* Effect.promise(() => first);
+ expect(result._tag).toBe("Success");
+ expect(registry.get(state)).toBe("requested");
+ expect(registry.get(otherView)).toBe("requested");
+ yield* Effect.promise(() =>
+ atoms.rerunCi.run(registry, {
+ ...target,
+ input: { ...target.input, target: { kind: "all" } },
+ }),
+ );
+ expect(writes).toBe(1);
+ const refreshed = Latch.makeUnsafe();
+ const stop = registry.subscribe(state, (phase) => {
+ if (phase === "idle") refreshed.openUnsafe();
+ });
+ yield* Effect.addFinalizer(() => Effect.sync(stop));
+ status = nextStatus;
+ yield* PubSub.publish(events, { revision: 2, reference, listings: true });
+ yield* refreshed.await;
+ expect(registry.get(otherView)).toBe("idle");
+ expect((yield* AtomRegistry.getResult(registry, runs)).runs[0]?.status).toBe(nextStatus);
+ }),
+ ),
+ );
+}
+
it.effect("refreshes pull request activity after a comment is updated", () =>
Effect.scoped(
Effect.gen(function* () {
diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts
index 87ca8db15a8f..1974b9e7d7b6 100644
--- a/packages/client-runtime/src/state/pullRequests.ts
+++ b/packages/client-runtime/src/state/pullRequests.ts
@@ -3,6 +3,8 @@ import {
type PullRequestDetail,
type PullRequestDiffInput,
type PullRequestSummary,
+ type PullRequestCiRuns,
+ type PullRequestCiRunInput,
type PullRequestRef,
type PullRequestRefresh,
type EnvironmentId,
@@ -215,11 +217,125 @@ export function pullRequestDetailToVcsStatus(
};
}
-/**
- * Reopening a PR within a minute reuses detail and activity. Explicit refreshes and
- * turn notifications still revalidate. Mutations run serially per environment: actions on the same
- * pull request are order-sensitive, and the detail view refetches after each one.
- */
+/** CI reads and submission state shared by every mounted view in an environment. */
+export function createPullRequestCiEnvironmentAtoms(
+ runtime: Atom.AtomRuntime,
+ refreshes = createPullRequestRefreshAtomFamily(runtime),
+ commandScheduler = createAtomCommandScheduler(),
+) {
+ const serialPerEnvironment = {
+ mode: "serial",
+ key: ({ environmentId }: { readonly environmentId: string }) => environmentId,
+ } as const;
+ const ciRunsQuery = createEnvironmentRpcQueryAtomFamily(runtime, {
+ label: "environment-data:pull-requests:ciRuns",
+ tag: WS_METHODS.pullRequestsCiRuns,
+ staleTimeMs: 15_000,
+ refreshTrigger: ({ environmentId, input }) =>
+ refreshes({ environmentId, input: { kind: "reference", reference: input } }),
+ });
+ const ciRuns = ({ environmentId, input }: Parameters[0]) =>
+ ciRunsQuery({
+ environmentId,
+ input: {
+ projectId: input.projectId,
+ ...(input.host === undefined ? {} : { host: input.host }),
+ repository: input.repository,
+ number: input.number,
+ },
+ });
+ const rerunState = Atom.family((key: string) => {
+ const { environmentId, input } = JSON.parse(key) as {
+ environmentId: EnvironmentId;
+ input: PullRequestRef & { readonly runId: string };
+ };
+ const runs = ciRuns({ environmentId, input });
+ const submission = Atom.make<
+ | { readonly phase: "pending" }
+ | { readonly phase: "requested"; readonly observed: PullRequestCiRuns | undefined }
+ | null
+ >(null).pipe(Atom.setIdleTTL(60_000));
+ const state = Atom.make((get): "idle" | "pending" | "requested" => {
+ const current = get(submission);
+ if (current === null) return "idle";
+ if (current.phase === "pending") return "pending";
+ const result = get(runs);
+ // A fresh host response ends the acknowledgement even on hosts without run attempts.
+ const observed = Option.getOrUndefined(AsyncResult.value(result));
+ return observed === current.observed ? "requested" : "idle";
+ });
+ return { submission, state, runs };
+ });
+ const stateFor = (target: {
+ readonly environmentId: EnvironmentId;
+ readonly input: PullRequestCiRunInput;
+ }) =>
+ rerunState(
+ JSON.stringify({
+ environmentId: target.environmentId,
+ input: {
+ projectId: target.input.projectId,
+ ...(target.input.host === undefined ? {} : { host: target.input.host }),
+ repository: target.input.repository,
+ number: target.input.number,
+ runId: target.input.runId,
+ },
+ }),
+ );
+ const requestRerun = createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:pull-requests:rerun-ci",
+ tag: WS_METHODS.pullRequestsRerunCi,
+ scheduler: commandScheduler,
+ concurrency: serialPerEnvironment,
+ });
+ const rerunCi: typeof requestRerun = {
+ label: requestRerun.label,
+ run: async (registry, target) => {
+ const { submission, state, runs } = stateFor(target);
+ if (registry.get(state) !== "idle") return AsyncResult.success(undefined);
+ const unmount = registry.mount(submission);
+ registry.set(submission, { phase: "pending" });
+ try {
+ const result = await requestRerun.run(registry, target);
+ const observed = Option.getOrUndefined(AsyncResult.value(registry.get(runs)));
+ registry.set(
+ submission,
+ result._tag === "Success" ? { phase: "requested", observed } : null,
+ );
+ return result;
+ } finally {
+ if (registry.get(submission)?.phase === "pending") registry.set(submission, null);
+ unmount();
+ }
+ },
+ };
+ return {
+ ciRuns,
+ ciRerunState: (target: Parameters[0]) => stateFor(target).state,
+ rerunCi,
+ detail: createEnvironmentRpcQueryAtomFamily(runtime, {
+ label: "environment-data:pull-requests:detail",
+ tag: WS_METHODS.pullRequestsDetail,
+ staleTimeMs: 60_000,
+ refreshTrigger: ({ environmentId, input }) =>
+ refreshes({ environmentId, input: { kind: "reference", reference: input } }),
+ }),
+ ciJobs: createEnvironmentRpcQueryAtomFamily(runtime, {
+ label: "environment-data:pull-requests:ciJobs",
+ tag: WS_METHODS.pullRequestsCiJobs,
+ staleTimeMs: 15_000,
+ refreshTrigger: ({ environmentId, input }) =>
+ refreshes({ environmentId, input: { kind: "reference", reference: input } }),
+ }),
+ invalidate: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:pull-requests:invalidate",
+ tag: WS_METHODS.pullRequestsInvalidate,
+ scheduler: commandScheduler,
+ concurrency: serialPerEnvironment,
+ }),
+ };
+}
+
export function createPullRequestEnvironmentAtoms(
runtime: Atom.AtomRuntime,
) {
@@ -282,13 +398,7 @@ export function createPullRequestEnvironmentAtoms(
input: { kind: "list", projectIds: input.refs.map((ref) => ref.projectId) },
}),
}),
- detail: createEnvironmentRpcQueryAtomFamily(runtime, {
- label: "environment-data:pull-requests:detail",
- tag: WS_METHODS.pullRequestsDetail,
- staleTimeMs: 60_000,
- refreshTrigger: ({ environmentId, input }) =>
- refreshes({ environmentId, input: { kind: "reference", reference: input } }),
- }),
+ ...createPullRequestCiEnvironmentAtoms(runtime, refreshes, commandScheduler),
/** One bounded repository relationship read for the open PR panel, never for list rows. */
dependencyContext: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:dependency-context",
@@ -443,16 +553,5 @@ export function createPullRequestEnvironmentAtoms(
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
- /**
- * Explicit refresh: forget the server's cached answers, then re-run the reads. A separate
- * request rather than a flag on a read, so only a person's refresh spends host requests
- * while every silent re-read shares the cache.
- */
- invalidate: createEnvironmentRpcCommand(runtime, {
- label: "environment-data:pull-requests:invalidate",
- tag: WS_METHODS.pullRequestsInvalidate,
- scheduler: commandScheduler,
- concurrency: serialPerEnvironment,
- }),
};
}
diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts
index 7b78b10992d9..b2f3dd458e4e 100644
--- a/packages/contracts/src/pullRequest.test.ts
+++ b/packages/contracts/src/pullRequest.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
PullRequestActionInput,
+ PullRequestCiRerunInput,
PullRequestCapabilities,
PullRequestDependencyContext,
PullRequestListInput,
@@ -17,6 +18,21 @@ const decodeListInput = Schema.decodeUnknownSync(PullRequestListInput);
const decodeReviewerRequest = Schema.decodeUnknownSync(PullRequestReviewerRequestInput);
const decodeAction = Schema.decodeUnknownSync(PullRequestActionInput);
+it("requires a job ID for a job rerun and accepts opaque provider IDs", () => {
+ const decode = Schema.decodeUnknownSync(PullRequestCiRerunInput);
+ const input = {
+ projectId: "project",
+ repository: "acme/web",
+ number: 1,
+ runId: "pipeline-uuid",
+ headSha: "head",
+ attempt: 1,
+ };
+ expect(decode({ ...input, target: { kind: "failed" } }).runId).toBe("pipeline-uuid");
+ expect(decode({ ...input, target: { kind: "job", jobId: "job-uuid" } }).target.kind).toBe("job");
+ expect(() => decode({ ...input, target: { kind: "job" } })).toThrow();
+});
+
const LIST_RESULT: PullRequestListResult = {
viewers: { "github.com": "bilal", "gitlab.com": "bilal.hassan" },
providers: [
diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts
index 40ea141bb87f..068abacf87d4 100644
--- a/packages/contracts/src/pullRequest.ts
+++ b/packages/contracts/src/pullRequest.ts
@@ -154,6 +154,40 @@ export const PullRequestCheck = Schema.Struct({
});
export type PullRequestCheck = typeof PullRequestCheck.Type;
+export const PullRequestCiRerunMode = Schema.Literals(["all", "failed"]);
+export const PullRequestCiRun = Schema.Struct({
+ id: TrimmedNonEmptyString,
+ name: TrimmedNonEmptyString,
+ url: Schema.NullOr(Schema.String),
+ status: PullRequestCheckStatus,
+ /** Zero when the host does not expose a run attempt counter. */
+ attempt: NonNegativeInt,
+ rerunModes: Schema.Array(PullRequestCiRerunMode),
+});
+export type PullRequestCiRun = typeof PullRequestCiRun.Type;
+
+export const PullRequestCiJob = Schema.Struct({
+ id: TrimmedNonEmptyString,
+ name: TrimmedNonEmptyString,
+ url: Schema.NullOr(Schema.String),
+ status: PullRequestCheckStatus,
+ canRerun: Schema.Boolean,
+});
+export type PullRequestCiJob = typeof PullRequestCiJob.Type;
+
+export const PullRequestCiRuns = Schema.Struct({
+ headSha: TrimmedNonEmptyString,
+ runs: Schema.Array(PullRequestCiRun),
+ truncated: Schema.Boolean,
+});
+export type PullRequestCiRuns = typeof PullRequestCiRuns.Type;
+
+export const PullRequestCiJobs = Schema.Struct({
+ jobs: Schema.Array(PullRequestCiJob),
+ truncated: Schema.Boolean,
+});
+export type PullRequestCiJobs = typeof PullRequestCiJobs.Type;
+
/**
* The reactions a remark can carry. GitHub's eight, which is also what the picker offers: GitLab
* accepts any emoji as an award, and the ones outside this set are read as nothing rather than
@@ -430,6 +464,8 @@ export type PullRequestDependencyCapabilities = typeof PullRequestDependencyCapa
* buttons.
*/
export const PullRequestCapabilities = Schema.Struct({
+ /** Native CI runs and jobs can be read separately from external status checks. */
+ ciRuns: Schema.optional(Schema.Boolean),
/** A unified patch can be fetched for the change request. */
diff: Schema.Boolean,
/** Viewed files can be read and saved to the connected host account. */
@@ -1112,6 +1148,26 @@ export const PullRequestActionInput = Schema.Struct({
});
export type PullRequestActionInput = typeof PullRequestActionInput.Type;
+export const PullRequestCiRunInput = Schema.Struct({
+ ...PullRequestRef.fields,
+ runId: TrimmedNonEmptyString,
+ headSha: TrimmedNonEmptyString,
+ attempt: NonNegativeInt,
+});
+export type PullRequestCiRunInput = typeof PullRequestCiRunInput.Type;
+
+export const PullRequestCiRerunTarget = Schema.Union([
+ Schema.Struct({ kind: PullRequestCiRerunMode }),
+ Schema.Struct({ kind: Schema.Literal("job"), jobId: TrimmedNonEmptyString }),
+]);
+export type PullRequestCiRerunTarget = typeof PullRequestCiRerunTarget.Type;
+
+export const PullRequestCiRerunInput = Schema.Struct({
+ ...PullRequestCiRunInput.fields,
+ target: PullRequestCiRerunTarget,
+});
+export type PullRequestCiRerunInput = typeof PullRequestCiRerunInput.Type;
+
// Not trimmed: the body is markdown, where leading spaces open a code block and two trailing
// spaces are a line break. GitHub rejects bodies past 65536 characters, so that bound is
// enforced here to keep oversized payloads off the wire and out of subprocess plumbing; the
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index f91950e0a7c9..5881fcbe246a 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -100,6 +100,10 @@ import {
} from "./provider.ts";
import { ProviderInstanceId } from "./providerInstance.ts";
import {
+ PullRequestCiRuns,
+ PullRequestCiJobs,
+ PullRequestCiRunInput,
+ PullRequestCiRerunInput,
PullRequestActionInput,
PullRequestActivity,
PullRequestCommentInput,
@@ -353,6 +357,9 @@ export const WS_METHODS = {
pullRequestsStack: "pullRequests.stack",
pullRequestsLinkedThreads: "pullRequests.linkedThreads",
pullRequestsDetail: "pullRequests.detail",
+ pullRequestsCiRuns: "pullRequests.ciRuns",
+ pullRequestsCiJobs: "pullRequests.ciJobs",
+ pullRequestsRerunCi: "pullRequests.rerunCi",
pullRequestsDependencyContext: "pullRequests.dependencyContext",
pullRequestsActivity: "pullRequests.activity",
pullRequestsThreadComments: "pullRequests.threadComments",
@@ -677,6 +684,24 @@ const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, {
error: PullRequestRpcError,
});
+const WsPullRequestsCiRunsRpc = Rpc.make(WS_METHODS.pullRequestsCiRuns, {
+ payload: PullRequestRef,
+ success: PullRequestCiRuns,
+ error: PullRequestRpcError,
+});
+
+const WsPullRequestsCiJobsRpc = Rpc.make(WS_METHODS.pullRequestsCiJobs, {
+ payload: PullRequestCiRunInput,
+ success: PullRequestCiJobs,
+ error: PullRequestRpcError,
+});
+
+const WsPullRequestsRerunCiRpc = Rpc.make(WS_METHODS.pullRequestsRerunCi, {
+ payload: PullRequestCiRerunInput,
+ success: Schema.Void,
+ error: PullRequestRpcError,
+});
+
const WsPullRequestsDependencyContextRpc = Rpc.make(WS_METHODS.pullRequestsDependencyContext, {
payload: PullRequestRef,
success: PullRequestDependencyContext,
@@ -1265,6 +1290,9 @@ export const WsRpcGroup = RpcGroup.make(
WsPullRequestsStackRpc,
WsPullRequestsLinkedThreadsRpc,
WsPullRequestsDetailRpc,
+ WsPullRequestsCiRunsRpc,
+ WsPullRequestsCiJobsRpc,
+ WsPullRequestsRerunCiRpc,
WsPullRequestsDependencyContextRpc,
WsPullRequestsActivityRpc,
WsPullRequestsThreadCommentsRpc,
From 47ea63dd55da91a1832d54f842a16c7450552484 Mon Sep 17 00:00:00 2001
From: Kalven Schraut
Date: Thu, 10 Sep 2026 10:02:10 -0500
Subject: [PATCH 2/3] fix(pull-requests): keep CI visible during workflow
approval
---
.../pullRequest/PullRequestService.test.ts | 6 ++++
.../pullRequest/PullRequestDetailPanel.tsx | 33 +++++++++----------
2 files changed, 22 insertions(+), 17 deletions(-)
diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts
index 17a020c68c63..74ec857ece31 100644
--- a/apps/server/src/pullRequest/PullRequestService.test.ts
+++ b/apps/server/src/pullRequest/PullRequestService.test.ts
@@ -5343,6 +5343,12 @@ it.effect("does not call CI adapters without an advertised capability", () =>
(yield* service.ciRuns(reference).pipe(Effect.flip))._tag,
"PullRequestOperationError",
);
+ assert.strictEqual(
+ (yield* service
+ .ciJobs({ ...reference, headSha: "head", runId: "12", attempt: 1 })
+ .pipe(Effect.flip))._tag,
+ "PullRequestOperationError",
+ );
assert.strictEqual(
(yield* service
.rerunCi({
diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
index 087b3978fd5c..11906a072a8d 100644
--- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
@@ -2426,7 +2426,7 @@ export function PullRequestDetailPanel({
))}
{tab === "summary" ? (
-
+
{workflowApprovalsRequired > 0 && can("approve-workflows") ? (
- ) : (
-
-
- {checksSummary}
-
- )}
+ ) : null}
+
+
+ {checksSummary}
+
) : tab === "timeline" ? (
From 05f01426daab4cbbbe4ff7bbd054cb422d0c9e09 Mon Sep 17 00:00:00 2001
From: Kalven Schraut
Date: Thu, 10 Sep 2026 17:48:15 -0500
Subject: [PATCH 3/3] fix(pull-requests): retain shared CI state across garbage
collection
---
.../src/state/pullRequests.test.ts | 17 +++++++
.../client-runtime/src/state/pullRequests.ts | 49 ++++++++++---------
2 files changed, 43 insertions(+), 23 deletions(-)
diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts
index 318c96a48d09..eaa5e335f290 100644
--- a/packages/client-runtime/src/state/pullRequests.test.ts
+++ b/packages/client-runtime/src/state/pullRequests.test.ts
@@ -41,6 +41,21 @@ const TARGET = new PrimaryConnectionTarget({
wsBaseUrl: "wss://environment.example.test",
});
+// With --execArgv=--expose-gc, stress atom identity between turns when WeakRefs can clear.
+const collectGarbage = Effect.promise(
+ () =>
+ new Promise((resolve) => {
+ const testRuntime = globalThis as typeof globalThis & {
+ setImmediate: (callback: () => void) => void;
+ gc?: () => void;
+ };
+ testRuntime.setImmediate(() => {
+ testRuntime.gc?.();
+ resolve();
+ });
+ }),
+);
+
function session(client: WsRpcProtocolClient): RpcSession {
return {
client,
@@ -329,6 +344,7 @@ for (const nextStatus of ["pending", "failure"] as const) {
);
yield* AtomRegistry.getResult(registry, runs);
yield* subscribed.await;
+ yield* collectGarbage;
const first = atoms.rerunCi.run(registry, {
...target,
input: { ...target.input, target: { kind: "all" } },
@@ -336,6 +352,7 @@ for (const nextStatus of ["pending", "failure"] as const) {
yield* started.await;
expect(registry.get(otherView)).toBe("pending");
expect(registry.get(otherHost)).toBe("idle");
+ yield* collectGarbage;
yield* Effect.promise(() =>
atoms.rerunCi.run(registry, {
...target,
diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts
index 1974b9e7d7b6..eba2dfdca78a 100644
--- a/packages/client-runtime/src/state/pullRequests.ts
+++ b/packages/client-runtime/src/state/pullRequests.ts
@@ -244,18 +244,22 @@ export function createPullRequestCiEnvironmentAtoms(
number: input.number,
},
});
+ // Atom.family uses weak references; registry nodes retain atoms directly while mounted.
+ const submissions = Atom.family((_key: string) =>
+ Atom.make<
+ | { readonly phase: "pending" }
+ | { readonly phase: "requested"; readonly observed: PullRequestCiRuns | undefined }
+ | null
+ >(null).pipe(Atom.setIdleTTL(60_000)),
+ );
const rerunState = Atom.family((key: string) => {
const { environmentId, input } = JSON.parse(key) as {
environmentId: EnvironmentId;
input: PullRequestRef & { readonly runId: string };
};
const runs = ciRuns({ environmentId, input });
- const submission = Atom.make<
- | { readonly phase: "pending" }
- | { readonly phase: "requested"; readonly observed: PullRequestCiRuns | undefined }
- | null
- >(null).pipe(Atom.setIdleTTL(60_000));
- const state = Atom.make((get): "idle" | "pending" | "requested" => {
+ const submission = submissions(key);
+ return Atom.make((get): "idle" | "pending" | "requested" => {
const current = get(submission);
if (current === null) return "idle";
if (current.phase === "pending") return "pending";
@@ -264,24 +268,21 @@ export function createPullRequestCiEnvironmentAtoms(
const observed = Option.getOrUndefined(AsyncResult.value(result));
return observed === current.observed ? "requested" : "idle";
});
- return { submission, state, runs };
});
- const stateFor = (target: {
+ const stateKey = (target: {
readonly environmentId: EnvironmentId;
readonly input: PullRequestCiRunInput;
}) =>
- rerunState(
- JSON.stringify({
- environmentId: target.environmentId,
- input: {
- projectId: target.input.projectId,
- ...(target.input.host === undefined ? {} : { host: target.input.host }),
- repository: target.input.repository,
- number: target.input.number,
- runId: target.input.runId,
- },
- }),
- );
+ JSON.stringify({
+ environmentId: target.environmentId,
+ input: {
+ projectId: target.input.projectId,
+ ...(target.input.host === undefined ? {} : { host: target.input.host }),
+ repository: target.input.repository,
+ number: target.input.number,
+ runId: target.input.runId,
+ },
+ });
const requestRerun = createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:rerun-ci",
tag: WS_METHODS.pullRequestsRerunCi,
@@ -291,8 +292,10 @@ export function createPullRequestCiEnvironmentAtoms(
const rerunCi: typeof requestRerun = {
label: requestRerun.label,
run: async (registry, target) => {
- const { submission, state, runs } = stateFor(target);
- if (registry.get(state) !== "idle") return AsyncResult.success(undefined);
+ const key = stateKey(target);
+ if (registry.get(rerunState(key)) !== "idle") return AsyncResult.success(undefined);
+ const submission = submissions(key);
+ const runs = ciRuns(target);
const unmount = registry.mount(submission);
registry.set(submission, { phase: "pending" });
try {
@@ -311,7 +314,7 @@ export function createPullRequestCiEnvironmentAtoms(
};
return {
ciRuns,
- ciRerunState: (target: Parameters[0]) => stateFor(target).state,
+ ciRerunState: (target: Parameters[0]) => rerunState(stateKey(target)),
rerunCi,
detail: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:detail",