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
412 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 /// so a new account made with it gets the invitation it came with.
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 NeedsUsername {
121 pending: String,
122 login: String,
123 suggestion: String,
124 next: String,
125 },
126}
127
128#[derive(Debug, Serialize, Deserialize)]
129pub struct GithubPendingArgs {
130 pub pending: String,
131}
132
133/// A GitHub sign-in waiting on a username or on signing in to link.
134#[derive(Debug, Serialize, Deserialize)]
135pub struct GithubPending {
136 pub login: String,
137 /// `link` or `username`.
138 pub kind: String,
139 pub suggestion: Option<String>,
140 pub next: String,
141}
142
143#[derive(Debug, Serialize, Deserialize)]
144pub struct GithubSignUpArgs {
145 pub pending: String,
146 pub username: String,
147}
148
149#[derive(Debug, Serialize, Deserialize)]
150pub struct GithubClaimArgs {
151 pub pending: String,
152 pub user: User,
153}
154
155/// A linked GitHub account. The numeric id is what identifies it; the
156/// login is for showing, and is refreshed at each sign-in.
157#[derive(Clone, Debug, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub struct GithubAccount {
160 pub github_id: u64,
161 pub login: String,
162 /// RFC 3339.
163 pub linked_at: String,
164 /// Whether g1t holds a working user token, needed to list installations.
165 pub authorized: bool,
166}
167
168#[derive(Debug, Default, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct GithubAccountView {
171 /// Whether this g1t has a GitHub App configured for sign-in.
172 pub enabled: bool,
173 pub account: Option<GithubAccount>,
174 /// Whether the account has a password, so GitHub is not its only way in.
175 pub has_password: bool,
176}
177
178#[derive(Debug, Serialize, Deserialize)]
179#[serde(rename_all = "camelCase")]
180pub struct GithubUserTokenArgs {
181 pub user_id: String,
182}
183
184#[derive(Debug, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct GithubRevokedArgs {
187 pub github_id: u64,
188}
189
190/// `github_usernames`: the g1t usernames of linked GitHub accounts. Returns
191/// a map from GitHub id, as a string, to username.
192#[derive(Debug, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub struct GithubUsernamesArgs {
195 pub github_ids: Vec<u64>,
196}
197
198// --- The app's installations and repositories (integrations) -------------
199
200/// How a GitHub repository comes to g1t.
201#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum GithubMode {
204 /// Copied once. The g1t repository is then its own.
205 Import,
206 /// g1t follows GitHub: every push there is fetched here.
207 Mirror,
208 /// GitHub follows g1t: every push here is pushed there.
209 Push,
210}
211
212impl GithubMode {
213 pub fn as_str(self) -> &'static str {
214 match self {
215 GithubMode::Import => "import",
216 GithubMode::Mirror => "mirror",
217 GithubMode::Push => "push",
218 }
219 }
220
221 pub fn parse(text: &str) -> GithubMode {
222 match text {
223 "mirror" => GithubMode::Mirror,
224 "push" => GithubMode::Push,
225 _ => GithubMode::Import,
226 }
227 }
228}
229
230#[derive(Clone, Debug, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232pub struct GithubInstallation {
233 pub id: u64,
234 pub workspace: String,
235 /// The GitHub user or organization it is installed on.
236 pub account: String,
237 /// `User` or `Organization`.
238 pub account_type: String,
239 /// `all` or `selected` repositories.
240 pub repository_selection: String,
241 pub suspended: bool,
242 /// Where to change which repositories it can see.
243 pub settings_url: String,
244 pub created_at: String,
245}
246
247#[derive(Debug, Serialize, Deserialize)]
248#[serde(rename_all = "camelCase")]
249pub struct GithubStatusArgs {
250 pub viewer: User,
251 pub workspace: String,
252}
253
254#[derive(Debug, Default, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct GithubAppStatus {
257 /// Whether this g1t has a GitHub App configured for repositories.
258 pub configured: bool,
259 /// `https://github.com/apps/<slug>/installations/new`.
260 pub install_url: Option<String>,
261 /// Whether the viewer has linked GitHub with a working user token.
262 pub linked: bool,
263 pub installations: Vec<GithubInstallation>,
264}
265
266#[derive(Debug, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct GithubInstallationArgs {
269 pub actor: User,
270 pub workspace: String,
271 pub installation_id: u64,
272}
273
274/// `github_visible_installations`: the app's installations the person's
275/// own GitHub account can see, for claiming one that was installed on
276/// GitHub directly rather than from g1t.
277#[derive(Debug, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct GithubVisibleArgs {
280 pub actor: User,
281}
282
283/// An installation of the app, as the person's GitHub account sees it.
284#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
285#[serde(rename_all = "camelCase")]
286pub struct GithubVisibleInstallation {
287 pub id: u64,
288 /// The GitHub user or organization it is installed on.
289 pub account: String,
290 /// `User` or `Organization`.
291 pub account_type: String,
292 /// `all` or `selected` repositories.
293 pub repository_selection: String,
294 pub suspended: bool,
295 pub settings_url: String,
296 /// The person's workspaces it is recorded in already.
297 pub recorded_in: Vec<String>,
298}
299
300#[derive(Debug, Serialize, Deserialize)]
301#[serde(rename_all = "camelCase")]
302pub struct GithubRepositoriesArgs {
303 pub actor: User,
304 pub workspace: String,
305 pub installation_id: u64,
306 #[serde(default)]
307 pub page: Option<u32>,
308}
309
310#[derive(Clone, Debug, Serialize, Deserialize)]
311#[serde(rename_all = "camelCase")]
312pub struct GithubRepository {
313 pub id: u64,
314 /// `owner/name`.
315 pub full_name: String,
316 pub name: String,
317 pub private: bool,
318 pub description: Option<String>,
319 pub default_branch: String,
320 /// The g1t repository already linked to it, as `workspace/name`.
321 pub linked_to: Option<String>,
322}
323
324#[derive(Debug, Default, Serialize, Deserialize)]
325#[serde(rename_all = "camelCase")]
326pub struct GithubRepositories {
327 pub repositories: Vec<GithubRepository>,
328 pub total: u32,
329 pub page: u32,
330 pub per_page: u32,
331}
332
333#[derive(Debug, Serialize, Deserialize)]
334#[serde(rename_all = "camelCase")]
335pub struct GithubImportArgs {
336 pub actor: User,
337 pub workspace: String,
338 pub installation_id: u64,
339 pub github_repo_id: u64,
340 /// The g1t repository's name; the GitHub one's when left out.
341 #[serde(default)]
342 pub name: Option<String>,
343 pub mode: GithubMode,
344 /// Private on g1t; as on GitHub when left out.
345 #[serde(default)]
346 pub private: Option<bool>,
347 /// Also copy issues, with their labels, milestone and state.
348 #[serde(default)]
349 pub issues: bool,
350}
351
352/// A g1t repository's tie to a GitHub repository.
353#[derive(Clone, Debug, Serialize, Deserialize)]
354#[serde(rename_all = "camelCase")]
355pub struct GithubRepoLink {
356 pub repo_id: String,
357 /// `workspace/name` on g1t.
358 pub repo: String,
359 pub installation_id: u64,
360 pub github_repo_id: u64,
361 /// `owner/name` on GitHub.
362 pub full_name: String,
363 pub mode: GithubMode,
364 /// RFC 3339; the last time refs were copied either way.
365 pub synced_at: Option<String>,
366 pub last_error: Option<String>,
367 /// Issues copied so far, when they were asked for.
368 pub issues_imported: u32,
369}
370
371#[derive(Debug, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373pub struct GithubLinkArgs {
374 pub repo_id: String,
375}
376
377#[derive(Debug, Serialize, Deserialize)]
378#[serde(rename_all = "camelCase")]
379pub struct GithubUnlinkRepoArgs {
380 pub actor: User,
381 pub repo_id: String,
382}
383
384#[derive(Debug, Serialize, Deserialize)]
385pub struct GithubReceiveArgs {
386 /// Header names in lowercase.
387 pub headers: HashMap<String, String>,
388 pub body: String,
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn a_visible_installation_reads_as_the_site_expects() {
397 let item = GithubVisibleInstallation {
398 id: 7,
399 account: "flagon-io".into(),
400 account_type: "Organization".into(),
401 repository_selection: "all".into(),
402 suspended: false,
403 settings_url: "https://github.com/organizations/flagon-io/settings/installations/7".into(),
404 recorded_in: vec!["flagon-io".into()],
405 };
406 let json = serde_json::to_value(&item).unwrap();
407 assert_eq!(json["accountType"], "Organization");
408 assert_eq!(json["repositorySelection"], "all");
409 assert_eq!(json["recordedIn"][0], "flagon-io");
410 assert_eq!(serde_json::from_value::<GithubVisibleInstallation>(json).unwrap(), item);
411 }
412}