Skip to content
169 linesCodeBlameRaw
1import type { ServiceBinding } from "./clients";
2import type { User } from "./identity";
3import type { Result } from "./result";
4
5/**
6 * GitHub: signing in with a GitHub account, and bringing repositories
7 * across through g1t's GitHub App. Mirrors `crates/contracts/src/github.rs`.
8 *
9 * The identity service keeps linked accounts and their user tokens; the
10 * integrations service keeps installations, the repositories imported
11 * through them and the app's webhook. With no app configured, `enabled`/`configured` are false
12 * and the site shows no GitHub buttons.
13 */
14
15export type GithubPurpose = "sign_in" | "link";
16
17/** How a return from GitHub ended. */
18export type GithubFinished =
19 | {
20 kind: "signed_in";
21 /** `twoFactorChallenge` is set, and `sessionToken` empty, while the account's two-factor code is still to come. */
22 signedIn: { user: User; sessionToken: string; twoFactorChallenge?: string | null };
23 created: boolean;
24 next: string;
25 }
26 | { kind: "linked"; login: string; next: string }
27 /** An account has one of its verified emails: sign in to it, then claim. */
28 | { kind: "needs_link"; pending: string; login: string; next: string }
29 /** A new account waiting on a username. */
30 | { kind: "needs_username"; pending: string; login: string; suggestion: string; next: string };
31
32export type GithubPending = {
33 login: string;
34 kind: "link" | "username";
35 suggestion: string | null;
36 next: string;
37};
38
39export type GithubAccount = {
40 githubId: number;
41 login: string;
42 linkedAt: string;
43 /** Whether g1t holds a working user token for it. */
44 authorized: boolean;
45};
46
47export type GithubAccountView = {
48 enabled: boolean;
49 account: GithubAccount | null;
50 hasPassword: boolean;
51};
52
53export type GithubInstallation = {
54 id: number;
55 workspace: string;
56 account: string;
57 accountType: string;
58 repositorySelection: "all" | "selected" | string;
59 suspended: boolean;
60 settingsUrl: string;
61 createdAt: string;
62};
63
64/**
65 * An installation of the app the person's own GitHub account can see,
66 * from `github_visible_installations`: how one installed on GitHub
67 * directly is found and added to a workspace.
68 */
69export type GithubVisibleInstallation = {
70 id: number;
71 account: string;
72 accountType: string;
73 repositorySelection: "all" | "selected" | string;
74 suspended: boolean;
75 settingsUrl: string;
76 /** The person's workspaces it is recorded in already. */
77 recordedIn: string[];
78};
79
80export type GithubAppStatus = {
81 configured: boolean;
82 installUrl: string | null;
83 linked: boolean;
84 installations: GithubInstallation[];
85};
86
87export type GithubRepository = {
88 id: number;
89 fullName: string;
90 name: string;
91 private: boolean;
92 description: string | null;
93 defaultBranch: string;
94 /** The g1t repository already imported from it, as `workspace/name`. */
95 linkedTo: string | null;
96};
97
98export type GithubRepositories = {
99 repositories: GithubRepository[];
100 total: number;
101 page: number;
102 perPage: number;
103};
104
105/** Where a g1t repository was imported from on GitHub. The import is copied once: the g1t repository is then its own. */
106export type GithubRepoLink = {
107 repoId: string;
108 repo: string;
109 installationId: number;
110 githubRepoId: number;
111 fullName: string;
112 /** RFC 3339: when it was imported. */
113 syncedAt: string | null;
114 lastError: string | null;
115 issuesImported: number;
116};
117
118export type GithubImportInput = {
119 installationId: number;
120 githubRepoId: number;
121 name?: string;
122 private?: boolean;
123 issues: boolean;
124};
125
126async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
127 const response = await service.fetch(`https://service/rpc/${method}`, {
128 method: "POST",
129 headers: { "content-type": "application/json" },
130 body: JSON.stringify(args),
131 });
132 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
133 return (await response.json()) as T;
134}
135
136/** Signing in with GitHub: methods of the identity service. */
137export function githubSignInClient(identity: ServiceBinding) {
138 const call = <T>(method: string, args: object) => rpc<T>(identity, method, args);
139 return {
140 enabled: () => call<boolean>("github_enabled", {}),
141 start: (input: { purpose: GithubPurpose; user?: User | null; redirectUri: string; next: string; inviteCode?: string | null }) =>
142 call<Result<{ authorizeUrl: string; state: string }>>("github_start", input),
143 finish: (state: string, code: string) => call<Result<GithubFinished>>("github_finish", { state, code }),
144 pending: (pending: string) => call<Result<GithubPending>>("github_pending", { pending }),
145 signUp: (pending: string, username: string) =>
146 call<Result<{ user: User; sessionToken: string }>>("github_sign_up", { pending, username }),
147 claim: (pending: string, user: User) => call<Result<GithubAccount>>("github_claim", { pending, user }),
148 account: (user: User) => call<GithubAccountView>("github_account", { user }),
149 unlink: (user: User) => call<Result<boolean>>("github_unlink", { user }),
150 };
151}
152
153/** g1t's GitHub App: methods of the integrations service. */
154export function githubAppClient(integrations: ServiceBinding) {
155 const call = <T>(method: string, args: object) => rpc<T>(integrations, method, args);
156 return {
157 status: (viewer: User, workspace: string) => call<Result<GithubAppStatus>>("github_status", { viewer, workspace }),
158 addInstallation: (actor: User, workspace: string, installationId: number) =>
159 call<Result<GithubInstallation>>("github_add_installation", { actor, workspace, installationId }),
160 visibleInstallations: (actor: User) => call<Result<GithubVisibleInstallation[]>>("github_visible_installations", { actor }),
161 removeInstallation: (actor: User, workspace: string, installationId: number) =>
162 call<Result<boolean>>("github_remove_installation", { actor, workspace, installationId }),
163 repositories: (actor: User, workspace: string, installationId: number, page?: number) =>
164 call<Result<GithubRepositories>>("github_repositories", { actor, workspace, installationId, page: page ?? null }),
165 import: (actor: User, workspace: string, input: GithubImportInput) =>
166 call<Result<GithubRepoLink>>("github_import", { actor, workspace, ...input }),
167 link: (repoId: string) => call<GithubRepoLink | null>("github_link", { repoId }),
168 };
169}