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) => ( +
+ + + {pullRequestCheckStatusLabel(job)} + {job.canRerun && ( + + )} +
+ ))} + {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 ( +
+
+ + + {run.url && ( + + )} +
+
+ + {pending + ? "Requesting rerun…" + : requested + ? "Rerun requested" + : pullRequestCheckStatusLabel(run)} + + {run.rerunModes.map((kind) => ( + + ))} +
+ {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

+ +
+ {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",