Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlAppAccess]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlStarred]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope,
[WS_METHODS.projectsListEntries]: AuthFilesystemReadScope,
[WS_METHODS.projectsListIgnoredFiles]: AuthFilesystemReadScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ const RPC_AGGREGATES = {
[WS_METHODS.sourceControlSignInWait]: "source-control",
[WS_METHODS.sourceControlSignInCancel]: "source-control",
[WS_METHODS.sourceControlAppAccess]: "source-control",
[WS_METHODS.sourceControlStarred]: "source-control",
[WS_METHODS.projectCloneStart]: "source-control",
[WS_METHODS.projectCloneCancel]: "source-control",
[WS_METHODS.projectCloneRetry]: "source-control",
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/sourceControl/SourceControlSignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import {
type SourceControlSignInInput,
type SourceControlSignInPrompt,
type SourceControlSignInResult,
type SourceControlStarredInput,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import * as SourceControlBuiltInDrivers from "./builtInDrivers.ts";

/** Signs the server in to a forge through the forge's own app, for hosts whose driver offers it. */
/** Signs the server in to a forge through the forge's own app, and reads what its account did. */
export class SourceControlSignIn extends Context.Service<
SourceControlSignIn,
{
Expand All @@ -26,6 +27,7 @@ export class SourceControlSignIn extends Context.Service<
input: SourceControlSignInInput,
) => Effect.Effect<void, SourceControlSignInError>;
readonly access: (input: SourceControlAppAccessInput) => Effect.Effect<SourceControlAppAccess>;
readonly starred: (input: SourceControlStarredInput) => Effect.Effect<boolean>;
}
>()("t3/sourceControl/SourceControlSignIn") {}

Expand Down Expand Up @@ -63,6 +65,11 @@ const make = Effect.gen(function* () {
signIn === undefined ? "signed-out" : yield* signIn.access(input.host, input.repository),
} satisfies SourceControlAppAccess;
}),
starred: Effect.fn("SourceControlSignIn.starred")(function* (input) {
const isStarred = drivers.find(({ driver }) => driver.kind === input.provider)?.instance
.isRepositoryStarred;
return isStarred === undefined ? false : yield* isStarred(input.host, input.repository);
}),
});
});

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2681,6 +2681,7 @@ export const layerWsRpc = (
[WS_METHODS.sourceControlSignInWait]: (input) => sourceControlSignIn.wait(input),
[WS_METHODS.sourceControlSignInCancel]: (input) => sourceControlSignIn.cancel(input),
[WS_METHODS.sourceControlAppAccess]: (input) => sourceControlSignIn.access(input),
[WS_METHODS.sourceControlStarred]: (input) => sourceControlSignIn.starred(input),
[WS_METHODS.projectCloneStart]: (input) =>
projectCloneTracker.start(input, {
createProject: (project) =>
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/components/GitHubStarPrompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { EnvironmentId } from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";

import type { toastManager } from "./ui/toast";

const { add, starred } = vi.hoisted(() => ({
add: vi.fn<typeof toastManager.add>(),
starred: { value: false, duringCheck: () => {} },
}));
vi.mock("./ui/toast", () => ({ toastManager: { add, close: vi.fn() } }));
vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: {} }));
vi.mock("../state/sourceControl", () => ({ sourceControlEnvironment: { checkStarred: {} } }));
vi.mock("@t3tools/client-runtime/state/runtime", () => ({
runAtomCommand: async () => {
starred.duringCheck();
return { _tag: "Success", value: starred.value };
},
}));

const environmentId = EnvironmentId.make("environment-1");

function setup({ sent = 0, writable = true, alreadyStarred = false } = {}) {
add.mockReset();
starred.value = alreadyStarred;
starred.duringCheck = () => {};
const store = new Map([["t3code:github-star-prompt:sent", String(sent)]]);
vi.stubGlobal("window", {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
if (!writable) throw new Error("QuotaExceededError");
store.set(key, value);
},
},
});
return store;
}

// A fresh module import stands in for an app reload; storage survives it.
async function startSession() {
vi.resetModules();
const session = await import("./GitHubStarPrompt");
return { send: () => session.recordPromptSent(environmentId) };
}

describe("recordPromptSent", () => {
it("asks after five prompts, once per session, and never again once closed", async () => {
setup();
let session = await startSession();
for (let sent = 0; sent < 5; sent++) await session.send();
expect(add).not.toHaveBeenCalled();

await session.send();
await session.send();
expect(add).toHaveBeenCalledOnce();

session = await startSession();
await session.send();
expect(add).toHaveBeenCalledTimes(2);

add.mock.lastCall?.[0].onClose?.();
session = await startSession();
for (let sent = 0; sent < 10; sent++) await session.send();
expect(add).toHaveBeenCalledTimes(2);
vi.unstubAllGlobals();
});

it("never asks when a dismissal could not be saved", async () => {
setup({ sent: 5, writable: false });
await (await startSession()).send();
expect(add).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});

it("never asks once the environment's GitHub account starred the repo", async () => {
setup({ sent: 5, alreadyStarred: true });
await (await startSession()).send();
starred.value = false;
await (await startSession()).send();
expect(add).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});

it("never asks when another tab dismisses it during the star check", async () => {
const store = setup({ sent: 5 });
starred.duringCheck = () => store.set("t3code:github-star-prompt:dismissed", "true");
await (await startSession()).send();
expect(add).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
});
94 changes: 94 additions & 0 deletions apps/web/src/components/GitHubStarPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { type EnvironmentId, SourceControlProviderKind } from "@t3tools/contracts";
import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
import * as Schema from "effect/Schema";
import { StarIcon } from "lucide-react";

import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage";
import { readLocalApi } from "../localApi";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { sourceControlEnvironment } from "../state/sourceControl";
import { toastManager } from "./ui/toast";

// Separate keys so a count written by another tab can never undo a dismissal.
const SENT_KEY = "t3code:github-star-prompt:sent";
const DISMISSED_KEY = "t3code:github-star-prompt:dismissed";
const REPOSITORY = "pingdotgg/t3code";
const PROMPTS_BEFORE_ASKING = 5;

let shownThisSession = false;

function dismiss() {
try {
setLocalStorageItem(DISMISSED_KEY, true, Schema.Boolean);
} catch {}
}

// Every close path, including both buttons, runs `onClose`, which saves the dismissal.
function showStarPrompt() {
const close = () => toastManager.close(id);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const id = toastManager.add({
title: "Enjoying T3 Code?",
description: "Give us a star on GitHub to let us know!",
timeout: 0,
onClose: dismiss,
actionProps: {
children: "Star on GitHub",
onClick: () => {
close();
void readLocalApi()
?.shell.openExternal(`https://github.com/${REPOSITORY}`)
.catch(() => undefined);
},
},
data: {
actionLayout: "stacked-end",
leadingIcon: <StarIcon aria-hidden className="size-4 text-warning-foreground" />,
secondaryActionProps: { children: "Dismiss", onClick: close },
secondaryActionVariant: "ghost",
},
});
}

// The server's GitHub account stands in for the user's; it may not be theirs on a shared server.
async function alreadyStarred(environmentId: EnvironmentId) {
const result = await runAtomCommand(
appAtomRegistry,
sourceControlEnvironment.checkStarred,
{
environmentId,
input: {
provider: SourceControlProviderKind.make("github"),
host: "github.com",
repository: REPOSITORY,
},
},
{ label: "check GitHub star", reportFailure: false, reportDefect: false },
);
return result._tag === "Success" && result.value;
}

/**
* Counts a sent prompt. After more than five, asks once per session for a star until dismissed,
* unless the environment's GitHub account already starred the repo.
*/
export async function recordPromptSent(environmentId: EnvironmentId): Promise<void> {
if (shownThisSession) return;
try {
if (getLocalStorageItem(DISMISSED_KEY, Schema.Boolean)) return;
const sent = (getLocalStorageItem(SENT_KEY, Schema.Number) ?? 0) + 1;
setLocalStorageItem(SENT_KEY, sent, Schema.Number);
if (sent <= PROMPTS_BEFORE_ASKING) return;
} catch {
// Storage that cannot be read or written could not keep a dismissal, so never ask.
return;
}
shownThisSession = true;
if (await alreadyStarred(environmentId)) return dismiss();
try {
// Another tab can dismiss while the check runs.
if (getLocalStorageItem(DISMISSED_KEY, Schema.Boolean)) return;
} catch {
return;
}
showStarPrompt();
}
2 changes: 2 additions & 0 deletions apps/web/src/components/chat/useComposerSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
} from "../../composer-logic";
import { resolvePlanFollowUpSubmission } from "../../proposedPlan";
import { type ChatMessage } from "../../types";
import { recordPromptSent } from "../GitHubStarPrompt";
import { stackedThreadToast, toastManager } from "../ui/toast";
import { newMessageId, newThreadId } from "~/lib/utils";
import { useNewThreadHandler } from "../../hooks/useHandleNewThread";
Expand Down Expand Up @@ -1368,6 +1369,7 @@ export function useComposerSend(input: ComposerSendInput) {
) {
handleNewThreadInActiveProject();
}
void recordPromptSent(environmentId);
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/client-runtime/src/state/sourceControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export function createSourceControlEnvironmentAtoms<R, E>(
label: "environment-data:source-control:check-app-access",
tag: WS_METHODS.sourceControlAppAccess,
}),
checkStarred: createEnvironmentRpcCommand(runtime, {
label: "environment-data:source-control:check-starred",
tag: WS_METHODS.sourceControlStarred,
}),
repository: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:source-control:repository",
tag: WS_METHODS.sourceControlLookupRepository,
Expand Down
10 changes: 10 additions & 0 deletions packages/contracts/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ import {
SourceControlRepositoryLookupInput,
SourceControlAppAccess,
SourceControlAppAccessInput,
SourceControlStarredInput,
SourceControlSignInError,
SourceControlSignInInput,
SourceControlSignInPrompt,
Expand Down Expand Up @@ -592,6 +593,7 @@ export const WS_METHODS = {
sourceControlSignInWait: "sourceControl.signInWait",
sourceControlSignInCancel: "sourceControl.signInCancel",
sourceControlAppAccess: "sourceControl.appAccess",
sourceControlStarred: "sourceControl.starred",
projectCloneStart: "projectClone.start",
projectCloneCancel: "projectClone.cancel",
projectCloneRetry: "projectClone.retry",
Expand Down Expand Up @@ -1242,6 +1244,13 @@ const WsSourceControlAppAccessRpc = Rpc.make(WS_METHODS.sourceControlAppAccess,
error: EnvironmentAuthorizationError,
});

// False when the server's forge account has not starred the repository or the forge cannot say.
const WsSourceControlStarredRpc = Rpc.make(WS_METHODS.sourceControlStarred, {
payload: SourceControlStarredInput,
success: Schema.Boolean,
error: EnvironmentAuthorizationError,
});

// Returns when the project exists and cloning starts; progress uses the subscription.
const WsProjectCloneStartRpc = Rpc.make(WS_METHODS.projectCloneStart, {
payload: ProjectCloneStartInput,
Expand Down Expand Up @@ -2070,6 +2079,7 @@ export const WsRpcGroup = RpcGroup.make(
WsSourceControlSignInWaitRpc,
WsSourceControlSignInCancelRpc,
WsSourceControlAppAccessRpc,
WsSourceControlStarredRpc,
WsProjectCloneStartRpc,
WsProjectCloneCancelRpc,
WsProjectCloneRetryRpc,
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/sourceControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,13 @@ export const SourceControlAppAccess = Schema.Struct({
});
export type SourceControlAppAccess = typeof SourceControlAppAccess.Type;

export const SourceControlStarredInput = Schema.Struct({
provider: SourceControlProviderKind,
host: TrimmedNonEmptyString,
repository: TrimmedNonEmptyString,
});
export type SourceControlStarredInput = typeof SourceControlStarredInput.Type;

export class SourceControlSignInError extends Schema.TaggedError<SourceControlSignInError>()(
"SourceControlSignInError",
{
Expand Down
2 changes: 2 additions & 0 deletions packages/source-control-core/src/server/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export interface SourceControlDriverInstance {
Scope.Scope
>;
readonly signIn?: SourceControlSignIn;
/** Whether the server's account on `host` starred `repository` (`owner/name`). */
readonly isRepositoryStarred?: (host: string, repository: string) => Effect.Effect<boolean>;
}

/** What the server gives every driver. A driver builds anything else it needs itself. */
Expand Down
43 changes: 43 additions & 0 deletions packages/source-control-github/src/server/driver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import { makeIsRepositoryStarred } from "./driver.ts";
import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts";
import * as GitHubRest from "./GitHubRest.ts";

describe("makeIsRepositoryStarred", () => {
it.effect("is true only when GitHub answers 204 for the repository", () => {
const paths: Array<string> = [];
const starred = new Set(["user/starred/pingdotgg/t3code"]);
const api = Layer.mock(GitHubRest.GitHubRest)({
rest: (input) => {
paths.push(input.path);
return starred.has(input.path)
? Effect.succeed({
status: 204,
headers: {},
body: "",
truncated: false,
invalidUtf8: false,
})
: Effect.fail(
new GitHubRequestPolicy.GitHubApiNotFoundError({
host: input.host,
operation: input.operation,
}),
);
},
});
return Effect.gen(function* () {
const isStarred = yield* makeIsRepositoryStarred;
assert.isTrue(yield* isStarred("github.com", "pingdotgg/t3code"));
assert.isFalse(yield* isStarred("github.com", "pingdotgg/uploadthing"));
assert.isFalse(yield* isStarred("github.com", "pingdotgg/t3code/../../user"));
assert.deepStrictEqual(paths, [
"user/starred/pingdotgg/t3code",
"user/starred/pingdotgg/uploadthing",
]);
}).pipe(Effect.provide(api));
});
});
Loading
Loading