Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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>`. | |
| A GitHub App installed straight on GitHub can be added to a workspace: Import from GitHub lists the installations your GitHub account can see that this workspace hasn't added, each with Add for owners, and coming back from GitHub without g1t's own start asks which of your workspaces to add it to instead of stopping; the GitHub guide says how. | 33 | //! - `github_visible_installations` takes `GithubVisibleArgs`, returns |
| 34 | //! `Outcome<Vec<GithubVisibleInstallation>>`. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 | ||
| 43 | use std::collections::HashMap; | |
| 44 | ||
| 45 | use serde::{Deserialize, Serialize}; | |
| 46 | ||
| 47 | use crate::User; | |
| 48 | use 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")] | |
| 53 | pub enum GithubPurpose { | |
| 54 | /// Sign in, or create an account. | |
| 55 | SignIn, | |
| 56 | /// Link GitHub to the signed-in account. | |
| 57 | Link, | |
| 58 | } | |
| 59 | ||
| 60 | impl 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")] | |
| 71 | pub 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")] | |
| 91 | pub struct GithubStart { | |
| 92 | pub authorize_url: String, | |
| 93 | pub state: String, | |
| 94 | } | |
| 95 | ||
| 96 | #[derive(Debug, Serialize, Deserialize)] | |
| 97 | pub 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")] | |
| 105 | pub 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)] | |
| 133 | pub 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)] | |
| 139 | pub 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)] | |
| 151 | pub 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)] | |
| 160 | pub 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")] | |
| 169 | pub 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")] | |
| 180 | pub 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")] | |
| 190 | pub struct GithubUserTokenArgs { | |
| 191 | pub user_id: String, | |
| 192 | } | |
| 193 | ||
| 194 | #[derive(Debug, Serialize, Deserialize)] | |
| 195 | #[serde(rename_all = "camelCase")] | |
| 196 | pub 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")] | |
| 204 | pub 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")] | |
| 213 | pub 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 | ||
| 222 | impl 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")] | |
| 242 | pub 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")] | |
| 259 | pub struct GithubStatusArgs { | |
| 260 | pub viewer: User, | |
| 261 | pub workspace: String, | |
| 262 | } | |
| 263 | ||
| 264 | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 265 | #[serde(rename_all = "camelCase")] | |
| 266 | pub 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")] | |
| 278 | pub struct GithubInstallationArgs { | |
| 279 | pub actor: User, | |
| 280 | pub workspace: String, | |
| 281 | pub installation_id: u64, | |
| 282 | } | |
| 283 | ||
| A GitHub App installed straight on GitHub can be added to a workspace: Import from GitHub lists the installations your GitHub account can see that this workspace hasn't added, each with Add for owners, and coming back from GitHub without g1t's own start asks which of your workspaces to add it to instead of stopping; the GitHub guide says how. | 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. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 287 | #[derive(Debug, Serialize, Deserialize)] |
| 288 | #[serde(rename_all = "camelCase")] | |
| A GitHub App installed straight on GitHub can be added to a workspace: Import from GitHub lists the installations your GitHub account can see that this workspace hasn't added, each with Add for owners, and coming back from GitHub without g1t's own start asks which of your workspaces to add it to instead of stopping; the GitHub guide says how. | 289 | pub 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")] | |
| 296 | pub 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")] | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 312 | pub 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")] | |
| 322 | pub 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")] | |
| 336 | pub 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")] | |
| 345 | pub 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")] | |
| 365 | pub 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")] | |
| 383 | pub struct GithubLinkArgs { | |
| 384 | pub repo_id: String, | |
| 385 | } | |
| 386 | ||
| 387 | #[derive(Debug, Serialize, Deserialize)] | |
| 388 | #[serde(rename_all = "camelCase")] | |
| 389 | pub struct GithubUnlinkRepoArgs { | |
| 390 | pub actor: User, | |
| 391 | pub repo_id: String, | |
| 392 | } | |
| 393 | ||
| 394 | #[derive(Debug, Serialize, Deserialize)] | |
| 395 | pub struct GithubReceiveArgs { | |
| 396 | /// Header names in lowercase. | |
| 397 | pub headers: HashMap<String, String>, | |
| 398 | pub body: String, | |
| 399 | } | |
| A GitHub App installed straight on GitHub can be added to a workspace: Import from GitHub lists the installations your GitHub account can see that this workspace hasn't added, each with Add for owners, and coming back from GitHub without g1t's own start asks which of your workspaces to add it to instead of stopping; the GitHub guide says how. | 400 | |
| 401 | #[cfg(test)] | |
| 402 | mod 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 | } |