diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index d5a17755bffa2..6049121b41a5d 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -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, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 99c1274a04973..1e3388e4136ad 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -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", diff --git a/apps/server/src/sourceControl/SourceControlSignIn.ts b/apps/server/src/sourceControl/SourceControlSignIn.ts index d6c2ab1a69a33..3dd9ca03e2062 100644 --- a/apps/server/src/sourceControl/SourceControlSignIn.ts +++ b/apps/server/src/sourceControl/SourceControlSignIn.ts @@ -5,6 +5,7 @@ import { type SourceControlSignInInput, type SourceControlSignInPrompt, type SourceControlSignInResult, + type SourceControlStarredInput, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -12,7 +13,7 @@ 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, { @@ -26,6 +27,7 @@ export class SourceControlSignIn extends Context.Service< input: SourceControlSignInInput, ) => Effect.Effect; readonly access: (input: SourceControlAppAccessInput) => Effect.Effect; + readonly starred: (input: SourceControlStarredInput) => Effect.Effect; } >()("t3/sourceControl/SourceControlSignIn") {} @@ -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); + }), }); }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 92a629e1afd13..b269de1ed5f62 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -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) => diff --git a/apps/web/src/components/GitHubStarPrompt.test.ts b/apps/web/src/components/GitHubStarPrompt.test.ts new file mode 100644 index 0000000000000..34f763646d6dd --- /dev/null +++ b/apps/web/src/components/GitHubStarPrompt.test.ts @@ -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(), + 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(); + }); +}); diff --git a/apps/web/src/components/GitHubStarPrompt.tsx b/apps/web/src/components/GitHubStarPrompt.tsx new file mode 100644 index 0000000000000..d157d72960d93 --- /dev/null +++ b/apps/web/src/components/GitHubStarPrompt.tsx @@ -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); + 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: , + 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 { + 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(); +} diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index a5fa30ffe15a9..a3bdcd2d9c502 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -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"; @@ -1368,6 +1369,7 @@ export function useComposerSend(input: ComposerSendInput) { ) { handleNewThreadInActiveProject(); } + void recordPromptSent(environmentId); } } diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts index dad3ad6a1c247..7af35dd95a4ff 100644 --- a/packages/client-runtime/src/state/sourceControl.ts +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -30,6 +30,10 @@ export function createSourceControlEnvironmentAtoms( 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, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 77d545a3f75ce..37ea5866ae733 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -395,6 +395,7 @@ import { SourceControlRepositoryLookupInput, SourceControlAppAccess, SourceControlAppAccessInput, + SourceControlStarredInput, SourceControlSignInError, SourceControlSignInInput, SourceControlSignInPrompt, @@ -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", @@ -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, @@ -2070,6 +2079,7 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlSignInWaitRpc, WsSourceControlSignInCancelRpc, WsSourceControlAppAccessRpc, + WsSourceControlStarredRpc, WsProjectCloneStartRpc, WsProjectCloneCancelRpc, WsProjectCloneRetryRpc, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 6ffb4b97066f2..3138c3985a4aa 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -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", { diff --git a/packages/source-control-core/src/server/driver.ts b/packages/source-control-core/src/server/driver.ts index c25179fa2d440..936742b0659d0 100644 --- a/packages/source-control-core/src/server/driver.ts +++ b/packages/source-control-core/src/server/driver.ts @@ -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; } /** What the server gives every driver. A driver builds anything else it needs itself. */ diff --git a/packages/source-control-github/src/server/driver.test.ts b/packages/source-control-github/src/server/driver.test.ts new file mode 100644 index 0000000000000..b97300eceaff3 --- /dev/null +++ b/packages/source-control-github/src/server/driver.test.ts @@ -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 = []; + 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)); + }); +}); diff --git a/packages/source-control-github/src/server/driver.ts b/packages/source-control-github/src/server/driver.ts index 023098b0b63f5..351933ce551ca 100644 --- a/packages/source-control-github/src/server/driver.ts +++ b/packages/source-control-github/src/server/driver.ts @@ -53,12 +53,32 @@ const makeSignIn = Effect.gen(function* () { } satisfies SourceControlSignIn; }); +// Only 204 means starred. Tokens without star access, a missing login, and 404 all read as false. +export const makeIsRepositoryStarred = Effect.gen(function* () { + const api = yield* GitHubRest.GitHubRest; + return Effect.fn("GitHubDriver.isRepositoryStarred")( + function* (host: string, repository: string) { + const segments = repository.split("/"); + if (segments.length !== 2) return false; + const response = yield* api.rest({ + host, + operation: "isRepositoryStarred", + path: `user/starred/${segments.map(encodeURIComponent).join("/")}`, + allowReserve: false, + }); + return response.status === 204; + }, + Effect.orElseSucceed(() => false), + ); +}); + const makeInstance = Effect.all({ sourceControl: GitHubSourceControlProvider.make, discovery: GitHubSourceControlProvider.makeDiscovery, pullRequests: GitHubPullRequestProvider.make, fetchMedia: GitHubMediaFetch.make, signIn: makeSignIn, + isRepositoryStarred: makeIsRepositoryStarred, }); export const driver = defineSourceControlDriver({