Skip to content
422 linesCodeBlameRaw
1//! GitHub: signing in with a GitHub account, and bringing repositories
2//! across through g1t's GitHub App. Mirrors `packages/contracts/src/github.ts`.
3//!
4//! One GitHub App does both. Its user authorization (OAuth web flow, with
5//! PKCE) signs people in and lets g1t list what they installed it on; its
6//! installations give g1t access to the repositories they chose. The app
7//! is optional: with none configured every method here says so, and the
8//! site shows no GitHub buttons.
9//!
10//! The identity service (`POST /rpc/<method>`) keeps linked accounts and
11//! their user tokens:
12//!
13//! - `github_enabled` (no arguments) returns `bool`.
14//! - `github_start` takes `GithubStartArgs`, returns `Outcome<GithubStart>`.
15//! - `github_finish` takes `GithubFinishArgs`, returns `Outcome<GithubFinished>`.
16//! - `github_pending` takes `GithubPendingArgs`, returns `Outcome<GithubPending>`.
17//! - `github_sign_up` takes `GithubSignUpArgs`, returns `Outcome<SignedIn>`.
18//! - `github_claim` takes `GithubClaimArgs`, returns `Outcome<GithubAccount>`.
19//! - `github_account` takes `UserArgs`, returns `GithubAccountView`.
20//! - `github_unlink` takes `UserArgs`, returns `Outcome<bool>`.
21//! - `github_user_token` takes `GithubUserTokenArgs`, returns `Outcome<String>`.
22//! - `github_revoked` takes `GithubRevokedArgs`, returns `u32`.
23//! - `github_usernames` takes `GithubUsernamesArgs`, returns a map.
24//!
25//! The integrations service keeps installations and linked repositories,
26//! and receives the app's webhook at `https://api.g1t.sh/hooks/github`:
27//!
28//! - `github_status` takes `GithubStatusArgs`, returns `Outcome<GithubAppStatus>`.
29//! - `github_add_installation` takes `GithubInstallationArgs`, returns
30//! `Outcome<GithubInstallation>`.
31//! - `github_remove_installation` takes `GithubInstallationArgs`, returns
32//! `Outcome<bool>`.
33//! - `github_visible_installations` takes `GithubVisibleArgs`, returns
34//! `Outcome<Vec<GithubVisibleInstallation>>`.
35//! - `github_repositories` takes `GithubRepositoriesArgs`, returns
36//! `Outcome<GithubRepositories>`.
37//! - `github_import` takes `GithubImportArgs`, returns `Outcome<GithubRepoLink>`.
38//! - `github_link` takes `GithubLinkArgs`, returns `Option<GithubRepoLink>`.
39//! - `github_unlink_repo` takes `GithubUnlinkRepoArgs`, returns `Outcome<bool>`.
40//! - `github_sync` takes `GithubUnlinkRepoArgs`, returns `Outcome<GithubRepoLink>`.
41//! - `github_receive` takes `GithubReceiveArgs`, returns `integrations::Received`.
42
43use std::collections::HashMap;
44
45use serde::{Deserialize, Serialize};
46
47use crate::User;
48use crate::identity::SignedIn;
49
50/// Why someone is sent to GitHub.
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum GithubPurpose {
54 /// Sign in, or create an account.
55 SignIn,
56 /// Link GitHub to the signed-in account.
57 Link,
58}
59
60impl GithubPurpose {
61 pub fn as_str(self) -> &'static str {
62 match self {
63 GithubPurpose::SignIn => "sign_in",
64 GithubPurpose::Link => "link",
65 }
66 }
67}
68
69#[derive(Debug, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct GithubStartArgs {
72 pub purpose: GithubPurpose,
73 /// The signed-in person, for `link`.
74 #[serde(default)]
75 pub user: Option<User>,
76 /// Exactly the callback registered on the app, such as
77 /// `https://g1t.sh/auth/github/callback`.
78 pub redirect_uri: String,
79 /// A same-site path to return to afterwards.
80 #[serde(default)]
81 pub next: String,
82 /// An invite code, from `/register?invite=…`, carried through GitHub
83 /// for a new account while g1t is invite-only.
84 #[serde(default)]
85 pub invite_code: Option<String>,
86}
87
88/// Where to send the browser, and the state to bind to it in a cookie.
89#[derive(Debug, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct GithubStart {
92 pub authorize_url: String,
93 pub state: String,
94}
95
96#[derive(Debug, Serialize, Deserialize)]
97pub struct GithubFinishArgs {
98 pub state: String,
99 pub code: String,
100}
101
102/// How a return from GitHub ended.
103#[derive(Debug, Serialize, Deserialize)]
104#[serde(tag = "kind", rename_all = "snake_case")]
105pub enum GithubFinished {
106 /// Signed in to the account the GitHub account is linked to, or to a
107 /// new one made with it.
108 SignedIn {
109 #[serde(rename = "signedIn")]
110 signed_in: SignedIn,
111 created: bool,
112 next: String,
113 },
114 /// Linked to the account that asked.
115 Linked { login: String, next: String },
116 /// An account with one of its verified emails exists: the person signs
117 /// in to it, and `github_claim` then links it.
118 NeedsLink { pending: String, login: String, next: String },
119 /// A new account, but the GitHub login cannot be its username.
120 /// A new account, but the GitHub login cannot be its username, or g1t
121 /// is invite-only and no invite code came with the sign-in.
122 NeedsUsername {
123 pending: String,
124 login: String,
125 suggestion: String,
126 next: String,
127 #[serde(rename = "inviteRequired")]
128 invite_required: bool,
129 },
130}
131
132#[derive(Debug, Serialize, Deserialize)]
133pub struct GithubPendingArgs {
134 pub pending: String,
135}
136
137/// A GitHub sign-in waiting on a username or on signing in to link.
138#[derive(Debug, Serialize, Deserialize)]
139pub struct GithubPending {
140 pub login: String,
141 /// `link` or `username`.
142 pub kind: String,
143 pub suggestion: Option<String>,
144 pub next: String,
145 /// Whether the person must give an invite code to create the account.
146 #[serde(rename = "inviteRequired", default)]
147 pub invite_required: bool,
148}
149
150#[derive(Debug, Serialize, Deserialize)]
151pub struct GithubSignUpArgs {
152 pub pending: String,
153 pub username: String,
154 /// Needed while g1t is invite-only, unless one came with the sign-in.
155 #[serde(rename = "inviteCode", default)]
156 pub invite_code: Option<String>,
157}
158
159#[derive(Debug, Serialize, Deserialize)]
160pub struct GithubClaimArgs {
161 pub pending: String,
162 pub user: User,
163}
164
165/// A linked GitHub account. The numeric id is what identifies it; the
166/// login is for showing, and is refreshed at each sign-in.
167#[derive(Clone, Debug, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct GithubAccount {
170 pub github_id: u64,
171 pub login: String,
172 /// RFC 3339.
173 pub linked_at: String,
174 /// Whether g1t holds a working user token, needed to list installations.
175 pub authorized: bool,
176}
177
178#[derive(Debug, Default, Serialize, Deserialize)]
179#[serde(rename_all = "camelCase")]
180pub struct GithubAccountView {
181 /// Whether this g1t has a GitHub App configured for sign-in.
182 pub enabled: bool,
183 pub account: Option<GithubAccount>,
184 /// Whether the account has a password, so GitHub is not its only way in.
185 pub has_password: bool,
186}
187
188#[derive(Debug, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct GithubUserTokenArgs {
191 pub user_id: String,
192}
193
194#[derive(Debug, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct GithubRevokedArgs {
197 pub github_id: u64,
198}
199
200/// `github_usernames`: the g1t usernames of linked GitHub accounts. Returns
201/// a map from GitHub id, as a string, to username.
202#[derive(Debug, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct GithubUsernamesArgs {
205 pub github_ids: Vec<u64>,
206}
207
208// --- The app's installations and repositories (integrations) -------------
209
210/// How a GitHub repository comes to g1t.
211#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "snake_case")]
213pub enum GithubMode {
214 /// Copied once. The g1t repository is then its own.
215 Import,
216 /// g1t follows GitHub: every push there is fetched here.
217 Mirror,
218 /// GitHub follows g1t: every push here is pushed there.
219 Push,
220}
221
222impl GithubMode {
223 pub fn as_str(self) -> &'static str {
224 match self {
225 GithubMode::Import => "import",
226 GithubMode::Mirror => "mirror",
227 GithubMode::Push => "push",
228 }
229 }
230
231 pub fn parse(text: &str) -> GithubMode {
232 match text {
233 "mirror" => GithubMode::Mirror,
234 "push" => GithubMode::Push,
235 _ => GithubMode::Import,
236 }
237 }
238}
239
240#[derive(Clone, Debug, Serialize, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub struct GithubInstallation {
243 pub id: u64,
244 pub workspace: String,
245 /// The GitHub user or organization it is installed on.
246 pub account: String,
247 /// `User` or `Organization`.
248 pub account_type: String,
249 /// `all` or `selected` repositories.
250 pub repository_selection: String,
251 pub suspended: bool,
252 /// Where to change which repositories it can see.
253 pub settings_url: String,
254 pub created_at: String,
255}
256
257#[derive(Debug, Serialize, Deserialize)]
258#[serde(rename_all = "camelCase")]
259pub struct GithubStatusArgs {
260 pub viewer: User,
261 pub workspace: String,
262}
263
264#[derive(Debug, Default, Serialize, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct GithubAppStatus {
267 /// Whether this g1t has a GitHub App configured for repositories.
268 pub configured: bool,
269 /// `https://github.com/apps/<slug>/installations/new`.
270 pub install_url: Option<String>,
271 /// Whether the viewer has linked GitHub with a working user token.
272 pub linked: bool,
273 pub installations: Vec<GithubInstallation>,
274}
275
276#[derive(Debug, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct GithubInstallationArgs {
279 pub actor: User,
280 pub workspace: String,
281 pub installation_id: u64,
282}
283
284/// `github_visible_installations`: the app's installations the person's
285/// own GitHub account can see, for claiming one that was installed on
286/// GitHub directly rather than from g1t.
287#[derive(Debug, Serialize, Deserialize)]
288#[serde(rename_all = "camelCase")]
289pub struct GithubVisibleArgs {
290 pub actor: User,
291}
292
293/// An installation of the app, as the person's GitHub account sees it.
294#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
295#[serde(rename_all = "camelCase")]
296pub struct GithubVisibleInstallation {
297 pub id: u64,
298 /// The GitHub user or organization it is installed on.
299 pub account: String,
300 /// `User` or `Organization`.
301 pub account_type: String,
302 /// `all` or `selected` repositories.
303 pub repository_selection: String,
304 pub suspended: bool,
305 pub settings_url: String,
306 /// The person's workspaces it is recorded in already.
307 pub recorded_in: Vec<String>,
308}
309
310#[derive(Debug, Serialize, Deserialize)]
311#[serde(rename_all = "camelCase")]
312pub struct GithubRepositoriesArgs {
313 pub actor: User,
314 pub workspace: String,
315 pub installation_id: u64,
316 #[serde(default)]
317 pub page: Option<u32>,
318}
319
320#[derive(Clone, Debug, Serialize, Deserialize)]
321#[serde(rename_all = "camelCase")]
322pub struct GithubRepository {
323 pub id: u64,
324 /// `owner/name`.
325 pub full_name: String,
326 pub name: String,
327 pub private: bool,
328 pub description: Option<String>,
329 pub default_branch: String,
330 /// The g1t repository already linked to it, as `workspace/name`.
331 pub linked_to: Option<String>,
332}
333
334#[derive(Debug, Default, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct GithubRepositories {
337 pub repositories: Vec<GithubRepository>,
338 pub total: u32,
339 pub page: u32,
340 pub per_page: u32,
341}
342
343#[derive(Debug, Serialize, Deserialize)]
344#[serde(rename_all = "camelCase")]
345pub struct GithubImportArgs {
346 pub actor: User,
347 pub workspace: String,
348 pub installation_id: u64,
349 pub github_repo_id: u64,
350 /// The g1t repository's name; the GitHub one's when left out.
351 #[serde(default)]
352 pub name: Option<String>,
353 pub mode: GithubMode,
354 /// Private on g1t; as on GitHub when left out.
355 #[serde(default)]
356 pub private: Option<bool>,
357 /// Also copy issues, with their labels, milestone and state.
358 #[serde(default)]
359 pub issues: bool,
360}
361
362/// A g1t repository's tie to a GitHub repository.
363#[derive(Clone, Debug, Serialize, Deserialize)]
364#[serde(rename_all = "camelCase")]
365pub struct GithubRepoLink {
366 pub repo_id: String,
367 /// `workspace/name` on g1t.
368 pub repo: String,
369 pub installation_id: u64,
370 pub github_repo_id: u64,
371 /// `owner/name` on GitHub.
372 pub full_name: String,
373 pub mode: GithubMode,
374 /// RFC 3339; the last time refs were copied either way.
375 pub synced_at: Option<String>,
376 pub last_error: Option<String>,
377 /// Issues copied so far, when they were asked for.
378 pub issues_imported: u32,
379}
380
381#[derive(Debug, Serialize, Deserialize)]
382#[serde(rename_all = "camelCase")]
383pub struct GithubLinkArgs {
384 pub repo_id: String,
385}
386
387#[derive(Debug, Serialize, Deserialize)]
388#[serde(rename_all = "camelCase")]
389pub struct GithubUnlinkRepoArgs {
390 pub actor: User,
391 pub repo_id: String,
392}
393
394#[derive(Debug, Serialize, Deserialize)]
395pub struct GithubReceiveArgs {
396 /// Header names in lowercase.
397 pub headers: HashMap<String, String>,
398 pub body: String,
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn a_visible_installation_reads_as_the_site_expects() {
407 let item = GithubVisibleInstallation {
408 id: 7,
409 account: "flagon-io".into(),
410 account_type: "Organization".into(),
411 repository_selection: "all".into(),
412 suspended: false,
413 settings_url: "https://github.com/organizations/flagon-io/settings/installations/7".into(),
414 recorded_in: vec!["flagon-io".into()],
415 };
416 let json = serde_json::to_value(&item).unwrap();
417 assert_eq!(json["accountType"], "Organization");
418 assert_eq!(json["repositorySelection"], "all");
419 assert_eq!(json["recordedIn"][0], "flagon-io");
420 assert_eq!(serde_json::from_value::<GithubVisibleInstallation>(json).unwrap(), item);
421 }
422}