Skip to content

Commit a17341a

Browse files
feat(pull-requests): inspect and rerun CI inline (#44)
Add provider-neutral CI run and job inspection with inline reruns for GitHub and Gitea Actions across web, desktop, and mobile. Validate host permissions and run identity, and share rerun state across mounted clients. Reviewed by Claude Fable 5.1 through the Claude CLI; approved commit 05f0142. Implemented with Codex.
1 parent 6190d8d commit a17341a

30 files changed

Lines changed: 2101 additions & 66 deletions

‎apps/mobile/src/features/threads/git/GitOverviewSheet.tsx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-wo
3535
import { vcsEnvironment } from "../../../state/vcs";
3636
import { resolveGitOverviewReviewNavigationAction } from "./git-overview-navigation";
3737
import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents";
38+
import { PullRequestCiSection } from "./PullRequestCiSection";
3839

3940
const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
4041

@@ -80,6 +81,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
8081
);
8182

8283
const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
84+
const pullRequest = selectedThread?.linkedPullRequest ?? selectedThread?.branchPullRequest;
8385
const currentStatusSummary = statusSummary(gitStatus.data);
8486
const currentWorktreePath = selectedThreadWorktreePath;
8587
const gitOperationLabel = gitState.gitOperationLabel;
@@ -335,13 +337,31 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
335337
});
336338
}}
337339
/>
340+
{selectedThread !== null && (
341+
<PullRequestCiSection
342+
environmentId={environmentId}
343+
reference={{
344+
projectId: selectedThread.projectId,
345+
host: link.host,
346+
repository: link.repository,
347+
number: link.number,
348+
}}
349+
/>
350+
)}
338351
</View>
339352
))}
340353
</View>
341354
))}
342355
</View>
343356
) : null}
344357

358+
{linkedPrChains.length === 0 && pullRequest && (
359+
<PullRequestCiSection
360+
key={`${environmentId}:${pullRequest.repository}:${pullRequest.number}`}
361+
environmentId={environmentId}
362+
reference={pullRequest}
363+
/>
364+
)}
345365
{currentWorktreePath ? <MetaCard label="Worktree" value={currentWorktreePath} /> : null}
346366
</ScrollView>
347367
);
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { useAtomValue } from "@effect/atom-react";
2+
import type {
3+
EnvironmentId,
4+
PullRequestCiRun,
5+
PullRequestCiRunInput,
6+
PullRequestCiRerunTarget,
7+
PullRequestRef,
8+
} from "@t3tools/contracts";
9+
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
10+
import { useState } from "react";
11+
import { Alert, Pressable, View } from "react-native";
12+
13+
import { AppText as Text } from "../../../components/AppText";
14+
import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
15+
import { pullRequestCiEnvironment } from "../../../state/pull-request-ci";
16+
import { useEnvironmentQuery } from "../../../state/query";
17+
import { useAtomCommand } from "../../../state/use-atom-command";
18+
19+
function Action({
20+
label,
21+
disabled,
22+
onPress,
23+
}: {
24+
label: string;
25+
disabled?: boolean;
26+
onPress: () => void;
27+
}) {
28+
return (
29+
<Pressable
30+
accessibilityRole="button"
31+
accessibilityLabel={label}
32+
disabled={disabled}
33+
onPress={onPress}
34+
className="min-h-11 justify-center px-2 disabled:opacity-40"
35+
>
36+
<Text className="text-sm text-primary">{label}</Text>
37+
</Pressable>
38+
);
39+
}
40+
41+
function open(url: string) {
42+
void tryOpenExternalUrl(url, "pull-request").then((opened) => {
43+
if (!opened) Alert.alert("Unable to open CI details");
44+
});
45+
}
46+
47+
function Jobs({
48+
environmentId,
49+
input,
50+
disabled,
51+
onRerun,
52+
}: {
53+
environmentId: EnvironmentId;
54+
input: PullRequestCiRunInput;
55+
disabled: boolean;
56+
onRerun: (target: PullRequestCiRerunTarget) => void;
57+
}) {
58+
const query = useEnvironmentQuery(pullRequestCiEnvironment.ciJobs({ environmentId, input }));
59+
if (query.error)
60+
return (
61+
<Text accessibilityRole="alert" className="text-sm text-danger">
62+
{query.error}
63+
</Text>
64+
);
65+
if (!query.data) return <Text className="text-sm text-muted-foreground">Loading jobs…</Text>;
66+
return (
67+
<View className="gap-1 pl-2">
68+
{query.data.jobs.map((job) => (
69+
<View key={job.id} className="flex-row items-center">
70+
<View className="flex-1">
71+
<Action label={job.name} disabled={!job.url} onPress={() => job.url && open(job.url)} />
72+
<Text className="text-xs text-muted-foreground">{job.status}</Text>
73+
</View>
74+
{job.canRerun && (
75+
<Action
76+
label="Rerun job"
77+
disabled={disabled || query.isPending}
78+
onPress={() => onRerun({ kind: "job", jobId: job.id })}
79+
/>
80+
)}
81+
</View>
82+
))}
83+
{query.data.truncated && (
84+
<Text className="text-xs text-muted-foreground">More jobs are available on the host.</Text>
85+
)}
86+
<Text className="text-xs text-muted-foreground">
87+
Rerunning a job may also rerun dependent jobs.
88+
</Text>
89+
</View>
90+
);
91+
}
92+
93+
function Run({
94+
environmentId,
95+
reference,
96+
headSha,
97+
run,
98+
disabled,
99+
}: {
100+
environmentId: EnvironmentId;
101+
reference: PullRequestRef;
102+
headSha: string;
103+
run: PullRequestCiRun;
104+
disabled: boolean;
105+
}) {
106+
const [expanded, setExpanded] = useState(false);
107+
const rerun = useAtomCommand(pullRequestCiEnvironment.rerunCi, { reportFailure: false });
108+
const input = { ...reference, runId: run.id, headSha, attempt: run.attempt };
109+
const submission = useAtomValue(pullRequestCiEnvironment.ciRerunState({ environmentId, input }));
110+
const pending = submission === "pending";
111+
const requested = submission === "requested";
112+
const submit = async (target: PullRequestCiRerunTarget) => {
113+
if (pending || requested || disabled) return;
114+
const result = await rerun({ environmentId, input: { ...input, target } });
115+
if (result._tag === "Failure") {
116+
const error = squashAtomCommandFailure(result);
117+
Alert.alert(
118+
"Unable to rerun CI",
119+
error instanceof Error ? error.message : "The host refused the rerun.",
120+
);
121+
}
122+
};
123+
return (
124+
<View className="gap-1 rounded-xl border border-border p-2">
125+
<Pressable
126+
accessibilityRole="button"
127+
accessibilityState={{ expanded }}
128+
onPress={() => setExpanded(!expanded)}
129+
className="min-h-11 justify-center"
130+
>
131+
<Text className="font-t3-bold text-foreground">{run.name}</Text>
132+
</Pressable>
133+
<Text accessibilityLiveRegion="polite" className="text-xs text-muted-foreground">
134+
{pending ? "Requesting rerun…" : requested ? "Rerun requested" : run.status}
135+
</Text>
136+
<View className="flex-row flex-wrap">
137+
{run.url && <Action label="Open run" onPress={() => open(run.url!)} />}
138+
{run.rerunModes.map((kind) => (
139+
<Action
140+
key={kind}
141+
label={kind === "all" ? "Rerun all" : "Rerun failed"}
142+
disabled={disabled || pending || requested}
143+
onPress={() => void submit({ kind })}
144+
/>
145+
))}
146+
</View>
147+
{expanded && (
148+
<Jobs
149+
environmentId={environmentId}
150+
input={input}
151+
disabled={disabled || pending || requested}
152+
onRerun={(target) => void submit(target)}
153+
/>
154+
)}
155+
</View>
156+
);
157+
}
158+
159+
function Runs({
160+
environmentId,
161+
reference,
162+
}: {
163+
environmentId: EnvironmentId;
164+
reference: PullRequestRef;
165+
}) {
166+
const query = useEnvironmentQuery(
167+
pullRequestCiEnvironment.ciRuns({ environmentId, input: reference }),
168+
);
169+
const invalidate = useAtomCommand(pullRequestCiEnvironment.invalidate);
170+
return (
171+
<View className="gap-2">
172+
<Action
173+
label="Refresh CI"
174+
disabled={query.isPending}
175+
onPress={() => {
176+
void invalidate({ environmentId, input: { reference } });
177+
}}
178+
/>
179+
{query.error && (
180+
<Text accessibilityRole="alert" className="text-sm text-danger">
181+
{query.error}
182+
</Text>
183+
)}
184+
{query.data ? (
185+
<>
186+
{query.data.runs.map((run) => (
187+
<Run
188+
key={`${query.data!.headSha}:${run.id}:${run.attempt}`}
189+
environmentId={environmentId}
190+
reference={reference}
191+
headSha={query.data!.headSha}
192+
run={run}
193+
disabled={query.isPending || query.error !== null}
194+
/>
195+
))}
196+
{query.data.runs.length === 0 && (
197+
<Text className="text-sm text-muted-foreground">No CI runs for this revision.</Text>
198+
)}
199+
{query.data.truncated && (
200+
<Text className="text-xs text-muted-foreground">
201+
More runs are available on the host.
202+
</Text>
203+
)}
204+
</>
205+
) : query.isPending ? (
206+
<Text className="text-sm text-muted-foreground">Loading CI runs…</Text>
207+
) : null}
208+
</View>
209+
);
210+
}
211+
212+
function Checks({
213+
environmentId,
214+
reference,
215+
}: {
216+
environmentId: EnvironmentId;
217+
reference: PullRequestRef;
218+
}) {
219+
const query = useEnvironmentQuery(
220+
pullRequestCiEnvironment.detail({ environmentId, input: reference }),
221+
);
222+
if (query.error)
223+
return (
224+
<Text accessibilityRole="alert" className="text-sm text-danger">
225+
{query.error}
226+
</Text>
227+
);
228+
if (!query.data) return <Text className="text-sm text-muted-foreground">Loading checks…</Text>;
229+
return (
230+
<View className="gap-2">
231+
{query.data.capabilities.ciRuns ? (
232+
<Runs environmentId={environmentId} reference={reference} />
233+
) : null}
234+
{query.data.checks.map((check) => (
235+
<View key={`${check.name}:${check.url ?? ""}`}>
236+
<Action
237+
label={check.name}
238+
disabled={!check.url}
239+
onPress={() => check.url && open(check.url)}
240+
/>
241+
<Text className="text-xs text-muted-foreground">{check.status}</Text>
242+
</View>
243+
))}
244+
{!query.data.capabilities.ciRuns && query.data.checks.length === 0 && (
245+
<Text className="text-sm text-muted-foreground">No checks reported.</Text>
246+
)}
247+
</View>
248+
);
249+
}
250+
251+
export function PullRequestCiSection({
252+
environmentId,
253+
reference,
254+
}: {
255+
environmentId: EnvironmentId;
256+
reference: PullRequestRef;
257+
}) {
258+
const [expanded, setExpanded] = useState(false);
259+
return (
260+
<View className="rounded-2xl border border-border bg-card p-3">
261+
<Pressable
262+
accessibilityRole="button"
263+
accessibilityState={{ expanded }}
264+
onPress={() => setExpanded(!expanded)}
265+
className="min-h-11 justify-center"
266+
>
267+
<Text className="font-t3-bold text-foreground">CI runs and checks</Text>
268+
</Pressable>
269+
{expanded && <Checks environmentId={environmentId} reference={reference} />}
270+
</View>
271+
);
272+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { createPullRequestCiEnvironmentAtoms } from "@t3tools/client-runtime/state/pull-requests";
2+
3+
import { connectionAtomRuntime } from "../connection/runtime";
4+
5+
export const pullRequestCiEnvironment = createPullRequestCiEnvironmentAtoms(connectionAtomRuntime);

‎apps/server/src/auth/RpcAuthorization.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ import { describe, expect, it } from "@effect/vitest";
1111
import { RPC_REQUIRED_SCOPES, requiredScopeForRpcMethod } from "./RpcAuthorization.ts";
1212

1313
describe("RPC authorization scopes", () => {
14+
it("lets readers inspect CI but requires operate scope to rerun it", () => {
15+
expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsCiRuns)).toBe(
16+
AuthOrchestrationReadScope,
17+
);
18+
expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsCiJobs)).toBe(
19+
AuthOrchestrationReadScope,
20+
);
21+
expect(requiredScopeForRpcMethod(WS_METHODS.pullRequestsRerunCi)).toBe(
22+
AuthOrchestrationOperateScope,
23+
);
24+
});
1425
it("declares exactly one scope for every RPC in the server group", () => {
1526
expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));
1627
});

‎apps/server/src/auth/RpcAuthorization.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export const RPC_REQUIRED_SCOPES = {
7171
[WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope,
7272
[WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope,
7373
[WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope,
74+
[WS_METHODS.pullRequestsCiRuns]: AuthOrchestrationReadScope,
75+
[WS_METHODS.pullRequestsCiJobs]: AuthOrchestrationReadScope,
76+
[WS_METHODS.pullRequestsRerunCi]: AuthOrchestrationOperateScope,
7477
[WS_METHODS.pullRequestsDependencyContext]: AuthOrchestrationReadScope,
7578
[WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
7679
[WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,

0 commit comments

Comments
 (0)