Skip to content

Commit 0c80751

Browse files
feat: sign in to GitHub through the T3 Code for GitHub app [internal testing only] (#26245)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 49a9e80 commit 0c80751

64 files changed

Lines changed: 2818 additions & 108 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/desktop/src/backend/DesktopBackendConfiguration.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ const WSL_FORWARDED_ENV_NAMES = [
8585
"OPENAI_API_KEY",
8686
"ANTHROPIC_API_KEY",
8787
"T3CODE_TELEMETRY_ENABLED",
88+
"T3CODE_GITHUB_APP_CLIENT_ID",
8889
// Otherwise the WSL server keeps exporting to endpoints from the bootstrap.
8990
"T3CODE_OTEL_SDK_DISABLED",
9091
"OTEL_SDK_DISABLED",

‎apps/desktop/src/shell/DesktopShellEnvironment.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ const LOGIN_SHELL_ENV_NAMES = [
9595
"SSL_CERT_DIR",
9696
"NODE_EXTRA_CA_CERTS",
9797
"T3CODE_TELEMETRY_ENABLED",
98+
"T3CODE_GITHUB_APP_CLIENT_ID",
9899
"__MISE_DIFF",
99100
] as const;
100101
const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
@@ -450,6 +451,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
450451
"SSL_CERT_DIR",
451452
"NODE_EXTRA_CA_CERTS",
452453
"T3CODE_TELEMETRY_ENABLED",
454+
"T3CODE_GITHUB_APP_CLIENT_ID",
453455
] as const) {
454456
if (!config.env[name] && shellEnvironment[name]) {
455457
config.env[name] = shellEnvironment[name];

‎apps/mobile/src/features/projects/AddProjectScreen.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli
8787
import { ThemedSwitch } from "../../components/ui/ThemedSwitch";
8888
import { uuidv4 } from "../../lib/uuid";
8989
import { useAtomCommand } from "../../state/use-atom-command";
90+
import { suggestAppAccessForRemote } from "./suggestAppAccess";
9091
import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
9192
import {
9293
useRemoteConnectionStatus,
@@ -997,6 +998,10 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
997998
);
998999
const environment = resolveAddProjectEnvironment(environmentOptions, selectedEnvironmentId);
9991000
const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false });
1001+
const checkPublishedAppAccess = useAtomCommand(sourceControlEnvironment.checkAppAccess, {
1002+
reportFailure: false,
1003+
reportDefect: false,
1004+
});
10001005
const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, {
10011006
reportFailure: false,
10021007
});
@@ -1095,6 +1100,12 @@ export function AddProjectNewScreen(props: { readonly environmentId?: string | s
10951100
`Could not create the ${publishTarget.definition.label} repository`,
10961101
errorMessage(Cause.squash(publishResult.cause)),
10971102
);
1103+
} else {
1104+
void suggestAppAccessForRemote(
1105+
checkPublishedAppAccess,
1106+
environment.environmentId,
1107+
publishResult.value.remoteUrl,
1108+
);
10981109
}
10991110
});
11001111
}
@@ -1315,6 +1326,10 @@ export function AddProjectDestinationScreen(props: {
13151326
const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
13161327
reportFailure: false,
13171328
});
1329+
const checkAppAccess = useAtomCommand(sourceControlEnvironment.checkAppAccess, {
1330+
reportFailure: false,
1331+
reportDefect: false,
1332+
});
13181333
const navigation = useNavigation();
13191334
const environment = useEnvironmentFromParam(props.environmentId);
13201335
const canWriteSourceControl = useEnvironmentScope(
@@ -1376,6 +1391,7 @@ export function AddProjectDestinationScreen(props: {
13761391
if (AsyncResult.isFailure(startResult)) {
13771392
setError(errorMessage(Cause.squash(startResult.cause)));
13781393
} else {
1394+
void suggestAppAccessForRemote(checkAppAccess, environment.environmentId, remoteUrl);
13791395
// Wait for the create event; the client store otherwise falls back to the project picker and loses clone controls.
13801396
const project = await waitForProject(
13811397
{ environmentId: environment.environmentId, projectId },
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type {
2+
EnvironmentId,
3+
SourceControlAppAccess,
4+
SourceControlAppAccessInput,
5+
} from "@t3tools/contracts";
6+
import { appSignInTargetForRemote } from "@t3tools/client-runtime/source-control-clients";
7+
import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
8+
import { Alert, Linking } from "react-native";
9+
10+
/**
11+
* After a clone, suggests installing the forge app on the repository's owner when the server is
12+
* signed in with it but the app does not reach the repository.
13+
*/
14+
export async function suggestAppAccessForRemote(
15+
check: (value: {
16+
readonly environmentId: EnvironmentId;
17+
readonly input: SourceControlAppAccessInput;
18+
}) => Promise<AtomCommandResult<SourceControlAppAccess, unknown>>,
19+
environmentId: EnvironmentId,
20+
remoteUrl: string,
21+
): Promise<void> {
22+
const target = appSignInTargetForRemote(remoteUrl);
23+
if (target === null) return;
24+
const result = await check({ environmentId, input: target.input });
25+
if (result._tag !== "Success" || result.value.status !== "not-covered") return;
26+
const owner = target.input.repository.split("/")[0] ?? target.input.repository;
27+
Alert.alert(
28+
`Give ${target.appSignIn.appName} access to ${owner}`,
29+
`It isn't installed there, so GitHub requests for ${target.input.repository} use your own login and its lower rate limit.`,
30+
[
31+
{ text: "Not now", style: "cancel" },
32+
{
33+
text: "Install",
34+
onPress: () => void Linking.openURL(target.appSignIn.installUrl).catch(() => undefined),
35+
},
36+
],
37+
);
38+
}

‎apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import { pullRequestLabelColor } from "./pullRequestList";
8282
import { PullRequestReactions } from "./PullRequestReactions";
8383
import { composerPullRequests } from "../../state/pull-requests";
8484
import { useEnvironmentQuery } from "../../state/query";
85+
import { SourceControlAppAccessNotice } from "./SourceControlAppAccessNotice";
8586
import { usePullToRefresh } from "../../state/use-pull-to-refresh";
8687
import {
8788
resolveNativeReviewDiffView,
@@ -720,6 +721,12 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
720721
<View>
721722
{tab === "overview" && identity ? (
722723
<View>
724+
<SourceControlAppAccessNotice
725+
environmentId={params.environmentId}
726+
provider={identity.provider}
727+
host={params.host}
728+
repository={params.repository}
729+
/>
723730
{pr ? (
724731
<PullRequestOverview
725732
pr={pr}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { EnvironmentId } from "@t3tools/contracts";
2+
import {
3+
readHostCredentialSettings,
4+
sourceControlClients,
5+
} from "@t3tools/client-runtime/source-control-clients";
6+
import { Linking, Pressable, View } from "react-native";
7+
8+
import { AppText as Text } from "../../components/ui/AppText";
9+
import { useEnvironmentPresentation } from "../../state/presentation";
10+
import { useEnvironmentQuery } from "../../state/query";
11+
import { sourceControlEnvironment } from "../../state/sourceControl";
12+
13+
/**
14+
* Says when the server's forge app sign-in cannot reach `repository`, so requests there fall back
15+
* to the user's own login. Renders nothing for servers that do not use the app.
16+
*/
17+
export function SourceControlAppAccessNotice(props: {
18+
readonly environmentId: string;
19+
readonly provider: string | undefined;
20+
readonly host: string | undefined;
21+
/** `owner/name`. */
22+
readonly repository: string;
23+
}) {
24+
const environmentId = EnvironmentId.make(props.environmentId);
25+
const { presentation } = useEnvironmentPresentation(environmentId);
26+
const definition = props.provider ? sourceControlClients.find(props.provider) : undefined;
27+
const appSignIn = definition?.credentials?.appSignIn;
28+
const host = (props.host ?? definition?.publicHost ?? "").toLowerCase();
29+
const settings = presentation?.serverConfig?.settings;
30+
const signedIn =
31+
definition !== undefined &&
32+
settings !== undefined &&
33+
(readHostCredentialSettings(settings, definition.kind).signIns[host] ?? "").length > 0;
34+
const access = useEnvironmentQuery(
35+
definition !== undefined && appSignIn?.hosts.includes(host) && signedIn
36+
? sourceControlEnvironment.appAccess({
37+
environmentId,
38+
input: { provider: definition.kind, host, repository: props.repository },
39+
})
40+
: null,
41+
);
42+
if (appSignIn === undefined || access.data?.status !== "not-covered") return null;
43+
const owner = props.repository.split("/")[0] ?? props.repository;
44+
45+
return (
46+
<View className="mb-4 gap-1 rounded-2xl bg-grouped-card px-4 py-3">
47+
<Text className="text-sm text-foreground-muted">
48+
{appSignIn.appName} isn't installed on {owner}, so requests here use your own GitHub login
49+
and its lower rate limit.
50+
</Text>
51+
<Pressable
52+
accessibilityRole="link"
53+
onPress={() => void Linking.openURL(appSignIn.installUrl).catch(() => undefined)}
54+
>
55+
<Text className="text-sm font-t3-bold text-foreground">Install on {owner}</Text>
56+
</Pressable>
57+
</View>
58+
);
59+
}

‎apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ vi.mock("../../state/preferences", () => ({
4545
updateMobilePreferencesAtom: {},
4646
}));
4747
vi.mock("./components/AutoSettleDaysField", () => ({ AutoSettleDaysField: () => null }));
48+
vi.mock("./components/SourceControlSignInSection", () => ({
49+
SourceControlSignInSection: () => null,
50+
}));
4851
vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) }));
4952
vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({}) }));
5053
vi.mock("../../state/session", () => ({

‎apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { DatabaseStorageNotice } from "./components/DatabaseStorageNotice";
2828
import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow";
2929
import { SettingsSection } from "./components/SettingsSection";
3030
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
31+
import { SourceControlSignInSection } from "./components/SourceControlSignInSection";
3132
import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection";
3233
import { useSettingsEnvironmentFilter } from "./settings-environment-filter";
3334
import { useSettingsWriter } from "./useSettingsWriter";
@@ -382,6 +383,12 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
382383

383384
{props.page === "source-control" ? (
384385
<>
386+
{projectSelected ? null : (
387+
<SourceControlSignInSection
388+
environments={selectedTargets}
389+
canWrite={(environmentId) => writableEnvironments.has(environmentId)}
390+
/>
391+
)}
385392
<SettingsSection title="Pull requests">
386393
<SettingsSwitchRow
387394
icon={PullRequestGlyph.merged}

0 commit comments

Comments
 (0)