Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
101 linesCodeBlameRaw
1/**
2 * Reads Getting started for a workspace's owner: their own state from
3 * identity (`onboarding`), and each step's facts from the services that
4 * keep them, all at once. A service that does not answer leaves its fact
5 * null, and its step not done, rather than failing the page. Never awaited
6 * by a page: Home and the Workspace page stream it, and the dialog opens
7 * when it arrives (lib/onboarding.ts says when).
8 */
9import type { AiConnection, AskSetup, Onboarding, OnboardingChange, OnboardingStep, User, Viewer } from "@g1t/contracts";
10
11import { githubApp } from "./github.server";
12import { invitationFilterOf } from "./invites";
13import { type OnboardingFacts, type OnboardingView, stepsFrom, withSkipped } from "./onboarding";
14import { runners } from "./runners.server";
15import { ai, billing, identity } from "./services.server";
16import { roleIn } from "./session.server";
17import { workspaceProjects } from "./workspace-projects.server";
18
19/** Whether `viewer` is a person who owns `slug`: the only ones the guide is for. */
20export function guideIsFor(viewer: Viewer, slug: string): viewer is User {
21 return viewer != null && (viewer.kind ?? "user") === "user" && roleIn(viewer, slug) === "owner";
22}
23
24/** The g1t CLI names the token it signs in with `g1t CLI on <machine>` (crates/g1t, `g1t auth login`). */
25const CLI_TOKEN = /^g1t CLI\b/;
26
27const settle = <T,>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null);
28
29/** The facts each step is judged by, and the providers and Ask's setup the AI steps show. */
30async function factsFor(user: User, slug: string): Promise<{ facts: OnboardingFacts; connections: AiConnection[] | null; ask: AskSetup | null }> {
31 const [providers, ask, projects, github, tokens, apps, members, invites, free, own] = await Promise.all([
32 settle(ai.list(slug, user)),
33 settle(ai.askSettings(slug, user)),
34 settle(workspaceProjects(slug, user)),
35 settle(githubApp.status(user, slug)),
36 settle(identity.listAccessTokens(user)),
37 settle(identity.listOAuthGrants(user)),
38 settle(identity.listMembers(slug, user)),
39 settle(identity.workspaceInvites(slug, user)),
40 settle(billing.freeWorkspaces([slug])),
41 settle(runners.list(user, { workspace: slug })),
42 ]);
43 const connections = providers?.ok ? providers.value : null;
44 const setup = ask?.ok ? ask.value : null;
45 const facts: OnboardingFacts = {
46 providers: connections ? { total: connections.length, working: connections.filter((c) => !c.lastError).length } : null,
47 askReady: setup ? Boolean(setup.provider_id && !setup.problem) : null,
48 askWith: setup?.provider_id ? [setup.provider_name, setup.auto ? "Auto" : setup.model].filter(Boolean).join(" · ") : null,
49 projects: projects?.ok ? projects.value.length : null,
50 github: github?.ok ? { configured: github.value.configured, connected: github.value.installations.some((i) => !i.suspended) } : null,
51 cli: tokens ? tokens.some((token) => CLI_TOKEN.test(token.name)) : null,
52 harnessApp: apps ? apps.length > 0 : null,
53 people: members?.ok
54 ? {
55 others: members.value.filter((member) => member.username !== user.username).length,
56 pending: invites?.ok ? invites.value.filter((invite) => invitationFilterOf(invite.status) === "pending").length : 0,
57 }
58 : null,
59 invitesNeedPlan: free ? free.includes(slug) : false,
60 runners: own?.ok ? own.value.length : null,
61 };
62 return { facts, connections, ask: setup };
63}
64
65/**
66 * Getting started for `viewer` in `slug`, or null when it is not for them
67 * or identity did not answer. `opening`: for a page where it only opens by
68 * itself (Home, the Workspace page), so once the person has dismissed it
69 * the facts are not read at all and this is null.
70 */
71export async function loadOnboarding(viewer: Viewer, slug: string, opening = false): Promise<OnboardingView | null> {
72 if (!guideIsFor(viewer, slug)) return null;
73 const stateP = settle(identity.onboarding(viewer, slug));
74 if (opening) {
75 const state = await stateP;
76 if (!state?.ok || state.value.dismissed_at != null) return null;
77 }
78 const [state, read] = await Promise.all([stateP, factsFor(viewer, slug)]);
79 if (!state?.ok) return null;
80 const name = viewer.workspaces?.find((membership) => membership.slug === slug)?.name?.trim() || slug;
81 const { facts, connections, ask } = read;
82 return { slug, name, state: state.value, facts, connections, ask, steps: stepsFrom(facts, state.value) };
83}
84
85/** Saves a change to `user`'s own Getting started in `slug`: as kept, or the reason it was refused. */
86export async function saveOnboarding(user: User, slug: string, change: OnboardingChange): Promise<{ state: Onboarding } | { error: string }> {
87 try {
88 const saved = await identity.setOnboarding(user, slug, change);
89 return saved.ok ? { state: saved.value } : { error: saved.error.message };
90 } catch (error) {
91 console.error("onboarding: could not be saved", error);
92 return { error: "Getting started couldn't be saved. Try again." };
93 }
94}
95
96/** Skips `step`, or takes it back, keeping the other steps as the person left them. */
97export async function skipStep(user: User, slug: string, step: OnboardingStep, skip: boolean): Promise<{ state: Onboarding } | { error: string }> {
98 const current = await settle(identity.onboarding(user, slug));
99 if (!current?.ok) return { error: current ? current.error.message : "Getting started couldn't be read. Try again." };
100 return saveOnboarding(user, slug, { skipped: withSkipped(current.value.skipped, step, skip) });
101}