Skip to content
372 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 the repositories
26//! imported through them, 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_receive` takes `GithubReceiveArgs`, returns `integrations::Received`.
40
41use std::collections::HashMap;
42
43use serde::{Deserialize, Serialize};
44
45use crate::User;
46use crate::identity::SignedIn;
47
48/// Why someone is sent to GitHub.
49#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum GithubPurpose {
52 /// Sign in, or create an account.
53 SignIn,
54 /// Link GitHub to the signed-in account.
55 Link,
56}
57
58impl GithubPurpose {
59 pub fn as_str(self) -> &'static str {
60 match self {
61 GithubPurpose::SignIn => "sign_in",
62 GithubPurpose::Link => "link",
63 }
64 }
65}
66
67#[derive(Debug, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct GithubStartArgs {
70 pub purpose: GithubPurpose,
71 /// The signed-in person, for `link`.
72 #[serde(default)]
73 pub user: Option<User>,
74 /// Exactly the callback registered on the app, such as
75 /// `https://g1t.sh/auth/github/callback`.
76 pub redirect_uri: String,
77 /// A same-site path to return to afterwards.
78 #[serde(default)]
79 pub next: String,
80 /// An invite code, from `/register?invite=…`, carried through GitHub
81 /// so a new account made with it gets the invitation it came with.
82 #[serde(default)]
83 pub invite_code: Option<String>,
84}
85
86/// Where to send the browser, and the state to bind to it in a cookie.
87#[derive(Debug, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct GithubStart {
90 pub authorize_url: String,
91 pub state: String,
92}
93
94#[derive(Debug, Serialize, Deserialize)]
95pub struct GithubFinishArgs {
96 pub state: String,
97 pub code: String,
98}
99
100/// How a return from GitHub ended.
101#[derive(Debug, Serialize, Deserialize)]
102#[serde(tag = "kind", rename_all = "snake_case")]
103pub enum GithubFinished {
104 /// Signed in to the account the GitHub account is linked to, or to a
105 /// new one made with it.
106 SignedIn {
107 #[serde(rename = "signedIn")]
108 signed_in: SignedIn,
109 created: bool,
110 next: String,
111 },
112 /// Linked to the account that asked.
113 Linked { login: String, next: String },
114 /// An account with one of its verified emails exists: the person signs
115 /// in to it, and `github_claim` then links it.
116 NeedsLink { pending: String, login: String, next: String },
117 /// A new account, but the GitHub login cannot be its username.
118 NeedsUsername {
119 pending: String,
120 login: String,
121 suggestion: String,
122 next: String,
123 },
124}
125
126#[derive(Debug, Serialize, Deserialize)]
127pub struct GithubPendingArgs {
128 pub pending: String,
129}
130
131/// A GitHub sign-in waiting on a username or on signing in to link.
132#[derive(Debug, Serialize, Deserialize)]
133pub struct GithubPending {
134 pub login: String,
135 /// `link` or `username`.
136 pub kind: String,
137 pub suggestion: Option<String>,
138 pub next: String,
139}
140
141#[derive(Debug, Serialize, Deserialize)]
142pub struct GithubSignUpArgs {
143 pub pending: String,
144 pub username: String,
145}
146
147#[derive(Debug, Serialize, Deserialize)]
148pub struct GithubClaimArgs {
149 pub pending: String,
150 pub user: User,
151}
152
153/// A linked GitHub account. The numeric id is what identifies it; the
154/// login is for showing, and is refreshed at each sign-in.
155#[derive(Clone, Debug, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct GithubAccount {
158 pub github_id: u64,
159 pub login: String,
160 /// RFC 3339.
161 pub linked_at: String,
162 /// Whether g1t holds a working user token, needed to list installations.
163 pub authorized: bool,
164}
165
166#[derive(Debug, Default, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct GithubAccountView {
169 /// Whether this g1t has a GitHub App configured for sign-in.
170 pub enabled: bool,
171 pub account: Option<GithubAccount>,
172 /// Whether the account has a password, so GitHub is not its only way in.
173 pub has_password: bool,
174}
175
176#[derive(Debug, Serialize, Deserialize)]
177#[serde(rename_all = "camelCase")]
178pub struct GithubUserTokenArgs {
179 pub user_id: String,
180}
181
182#[derive(Debug, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct GithubRevokedArgs {
185 pub github_id: u64,
186}
187
188/// `github_usernames`: the g1t usernames of linked GitHub accounts. Returns
189/// a map from GitHub id, as a string, to username.
190#[derive(Debug, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase")]
192pub struct GithubUsernamesArgs {
193 pub github_ids: Vec<u64>,
194}
195
196// --- The app's installations and repositories (integrations) -------------
197
198#[derive(Clone, Debug, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct GithubInstallation {
201 pub id: u64,
202 pub workspace: String,
203 /// The GitHub user or organization it is installed on.
204 pub account: String,
205 /// `User` or `Organization`.
206 pub account_type: String,
207 /// `all` or `selected` repositories.
208 pub repository_selection: String,
209 pub suspended: bool,
210 /// Where to change which repositories it can see.
211 pub settings_url: String,
212 pub created_at: String,
213}
214
215#[derive(Debug, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct GithubStatusArgs {
218 pub viewer: User,
219 pub workspace: String,
220}
221
222#[derive(Debug, Default, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct GithubAppStatus {
225 /// Whether this g1t has a GitHub App configured for repositories.
226 pub configured: bool,
227 /// `https://github.com/apps/<slug>/installations/new`.
228 pub install_url: Option<String>,
229 /// Whether the viewer has linked GitHub with a working user token.
230 pub linked: bool,
231 pub installations: Vec<GithubInstallation>,
232}
233
234#[derive(Debug, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct GithubInstallationArgs {
237 pub actor: User,
238 pub workspace: String,
239 pub installation_id: u64,
240}
241
242/// `github_visible_installations`: the app's installations the person's
243/// own GitHub account can see, for claiming one that was installed on
244/// GitHub directly rather than from g1t.
245#[derive(Debug, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct GithubVisibleArgs {
248 pub actor: User,
249}
250
251/// An installation of the app, as the person's GitHub account sees it.
252#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct GithubVisibleInstallation {
255 pub id: u64,
256 /// The GitHub user or organization it is installed on.
257 pub account: String,
258 /// `User` or `Organization`.
259 pub account_type: String,
260 /// `all` or `selected` repositories.
261 pub repository_selection: String,
262 pub suspended: bool,
263 pub settings_url: String,
264 /// The person's workspaces it is recorded in already.
265 pub recorded_in: Vec<String>,
266}
267
268#[derive(Debug, Serialize, Deserialize)]
269#[serde(rename_all = "camelCase")]
270pub struct GithubRepositoriesArgs {
271 pub actor: User,
272 pub workspace: String,
273 pub installation_id: u64,
274 #[serde(default)]
275 pub page: Option<u32>,
276}
277
278#[derive(Clone, Debug, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase")]
280pub struct GithubRepository {
281 pub id: u64,
282 /// `owner/name`.
283 pub full_name: String,
284 pub name: String,
285 pub private: bool,
286 pub description: Option<String>,
287 pub default_branch: String,
288 /// The g1t repository already imported from it, as `workspace/name`.
289 pub linked_to: Option<String>,
290}
291
292#[derive(Debug, Default, Serialize, Deserialize)]
293#[serde(rename_all = "camelCase")]
294pub struct GithubRepositories {
295 pub repositories: Vec<GithubRepository>,
296 pub total: u32,
297 pub page: u32,
298 pub per_page: u32,
299}
300
301#[derive(Debug, Serialize, Deserialize)]
302#[serde(rename_all = "camelCase")]
303pub struct GithubImportArgs {
304 pub actor: User,
305 pub workspace: String,
306 pub installation_id: u64,
307 pub github_repo_id: u64,
308 /// The g1t repository's name; the GitHub one's when left out.
309 #[serde(default)]
310 pub name: Option<String>,
311 /// Private on g1t; as on GitHub when left out.
312 #[serde(default)]
313 pub private: Option<bool>,
314 /// Also copy issues, with their labels, milestone and state.
315 #[serde(default)]
316 pub issues: bool,
317}
318
319/// Where a g1t repository was imported from on GitHub. The import is
320/// copied once: the g1t repository is then its own.
321#[derive(Clone, Debug, Serialize, Deserialize)]
322#[serde(rename_all = "camelCase")]
323pub struct GithubRepoLink {
324 pub repo_id: String,
325 /// `workspace/name` on g1t.
326 pub repo: String,
327 pub installation_id: u64,
328 pub github_repo_id: u64,
329 /// `owner/name` on GitHub.
330 pub full_name: String,
331 /// RFC 3339; when it was imported.
332 pub synced_at: Option<String>,
333 pub last_error: Option<String>,
334 /// Issues copied so far, when they were asked for.
335 pub issues_imported: u32,
336}
337
338#[derive(Debug, Serialize, Deserialize)]
339#[serde(rename_all = "camelCase")]
340pub struct GithubLinkArgs {
341 pub repo_id: String,
342}
343
344#[derive(Debug, Serialize, Deserialize)]
345pub struct GithubReceiveArgs {
346 /// Header names in lowercase.
347 pub headers: HashMap<String, String>,
348 pub body: String,
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn a_visible_installation_reads_as_the_site_expects() {
357 let item = GithubVisibleInstallation {
358 id: 7,
359 account: "flagon-io".into(),
360 account_type: "Organization".into(),
361 repository_selection: "all".into(),
362 suspended: false,
363 settings_url: "https://github.com/organizations/flagon-io/settings/installations/7".into(),
364 recorded_in: vec!["flagon-io".into()],
365 };
366 let json = serde_json::to_value(&item).unwrap();
367 assert_eq!(json["accountType"], "Organization");
368 assert_eq!(json["repositorySelection"], "all");
369 assert_eq!(json["recordedIn"][0], "flagon-io");
370 assert_eq!(serde_json::from_value::<GithubVisibleInstallation>(json).unwrap(), item);
371 }
372}