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 | //! g1t's GitHub App: the installations a workspace records, the |
| 2 | //! repositories brought across through them, and the app's webhook. | |
| 3 | //! | |
| 4 | //! A person installs the app on a GitHub account, and GitHub sends them | |
| 5 | //! back to `g1t.sh/integrations/github/setup`. The site asks this service | |
| 6 | //! to record the installation against a workspace, which it does only after | |
| 7 | //! checking with the person's own GitHub user token (from identity) that | |
| 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. | 8 | //! the installation is one they can see. An installation made on GitHub |
| 9 | //! directly, or whose return lost g1t's state, is added the same way from | |
| 10 | //! the list `github_visible_installations` gives: what the person's token | |
| 11 | //! can see, and which of their workspaces have it already. The webhook | |
| 12 | //! never adds one, since it names no workspace. Repositories are listed with that | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 13 | //! same user token, so a person only ever sees what both they and the app |
| 14 | //! can reach. | |
| 15 | //! | |
| 16 | //! Git goes over HTTPS with an installation access token, which this | |
| 17 | //! service gets by signing a JWT as the app (see `github_jwt.rs`) and keeps, | |
| 18 | //! sealed, until five minutes before it expires. Tokens are opaque: no | |
| 19 | //! length or format is assumed. | |
| 20 | //! | |
| 21 | //! A repository comes across one of three ways (`GithubMode`): imported | |
| Merge branch 'mirroring' into artifacts-mode | 22 | //! once; mirrored, so g1t keeps a standby copy that follows GitHub; or |
| 23 | //! pushed, so GitHub follows g1t. Either way g1t holds a full copy, and the | |
| 24 | //! project built from it is hosted on g1t like any other. Mirrored and | |
| 25 | //! pushed repositories are links in `remotes` (see `remotes.rs`), which | |
| 26 | //! does everything after the import: following pushes, takeovers, | |
| 27 | //! hand-backs, moving in. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 28 | //! |
| 29 | //! The webhook, `https://api.g1t.sh/hooks/github`, is checked against | |
| 30 | //! GITHUB_APP_WEBHOOK_SECRET in constant time, de-duplicated by | |
| 31 | //! `X-GitHub-Delivery`, answered with 202 at once and acted on afterwards, | |
| 32 | //! as every inbound hook in this service is. | |
| 33 | ||
| 34 | use std::collections::{HashMap, HashSet}; | |
| 35 | ||
| 36 | use g1t_contracts::access::{self, Capability}; | |
| 37 | use g1t_contracts::github::*; | |
| 38 | use g1t_contracts::integrations::Received; | |
| Merge branch 'mirroring' into artifacts-mode | 39 | use g1t_contracts::mirrors::{MirrorActArgs, MirrorState, RepoMirror}; |
| 40 | use g1t_contracts::repos::{CreateArgs, Repo, RepoPath}; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 41 | use g1t_contracts::time::rfc3339; |
| 42 | use g1t_contracts::work::{Issue, IssueActionArgs, IssueReason, OpenIssueArgs}; | |
| 43 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, is_valid_repo_name}; | |
| 44 | use g1t_kit::{args, now_ms, reply}; | |
| 45 | use g1t_secrets::{self as crypto, Sealer}; | |
| 46 | use serde::Deserialize; | |
| 47 | use serde_json::{Value, json}; | |
| 48 | use worker::wasm_bindgen::JsValue; | |
| 49 | use worker::{Context, D1Database, Env, Fetcher, Method, Response, Result}; | |
| 50 | ||
| 51 | use crate::github_jwt; | |
| 52 | use crate::http::{self, Answer}; | |
| 53 | ||
| 54 | const API: &str = "https://api.github.com"; | |
| 55 | /// An installation token is used until this close to its expiry. | |
| 56 | const TOKEN_MARGIN_MS: u64 = 5 * 60 * 1000; | |
| 57 | /// The most issues copied in one import, and per page asked of GitHub. | |
| 58 | const MAX_ISSUES: usize = 200; | |
| 59 | const PER_PAGE: u32 = 50; | |
| 60 | /// How long a delivery id is remembered, to drop GitHub's retries. | |
| 61 | const DELIVERY_DAYS: u64 = 7; | |
| 62 | ||
| 63 | /// The app, as this g1t is configured. Only `configured()` ones are used. | |
| 64 | pub struct AppConfig { | |
| 65 | pub app_id: String, | |
| 66 | pub slug: String, | |
| 67 | pub client_id: String, | |
| 68 | pub private_key: Option<String>, | |
| 69 | pub webhook_secret: Option<String>, | |
| 70 | } | |
| 71 | ||
| 72 | impl AppConfig { | |
| 73 | pub fn from_env(env: &Env) -> Self { | |
| 74 | let var = |name: &str| env.var(name).map(|v| v.to_string().trim().to_owned()).unwrap_or_default(); | |
| 75 | let secret = |name: &str| { | |
| 76 | env.secret(name) | |
| 77 | .ok() | |
| 78 | .map(|value| value.to_string()) | |
| 79 | .filter(|value| !value.trim().is_empty()) | |
| 80 | }; | |
| 81 | AppConfig { | |
| 82 | app_id: var("GITHUB_APP_ID"), | |
| 83 | slug: var("GITHUB_APP_SLUG"), | |
| 84 | client_id: var("GITHUB_APP_CLIENT_ID"), | |
| 85 | private_key: secret("GITHUB_APP_PRIVATE_KEY"), | |
| 86 | webhook_secret: secret("GITHUB_APP_WEBHOOK_SECRET"), | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | pub fn configured(&self) -> bool { | |
| 91 | !self.slug.is_empty() && !self.issuer().is_empty() && self.private_key.is_some() | |
| 92 | } | |
| 93 | ||
| 94 | /// Who the app's JWTs say they are from: its client ID, as GitHub now | |
| 95 | /// recommends, or its app ID. | |
| 96 | pub fn issuer(&self) -> &str { | |
| 97 | if self.client_id.is_empty() { &self.app_id } else { &self.client_id } | |
| 98 | } | |
| 99 | ||
| 100 | pub fn install_url(&self) -> String { | |
| 101 | format!("https://github.com/apps/{}/installations/new", self.slug) | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | // --- Pure parts, tested below ---------------------------------------------- | |
| 106 | ||
| 107 | /// Milliseconds since the epoch of an RFC 3339 UTC time such as GitHub's | |
| 108 | /// `2026-10-05T12:00:00Z`. | |
| 109 | pub fn parse_time(text: &str) -> Option<u64> { | |
| 110 | let text = text.trim().trim_end_matches('Z'); | |
| 111 | let (date, time) = text.split_once('T')?; | |
| 112 | let mut date = date.splitn(3, '-').map(|part| part.parse::<i64>().ok()); | |
| 113 | let (year, month, day) = (date.next()??, date.next()??, date.next()??); | |
| 114 | let mut time = time.splitn(3, ':'); | |
| 115 | let hour: i64 = time.next()?.parse().ok()?; | |
| 116 | let minute: i64 = time.next()?.parse().ok()?; | |
| 117 | let second: f64 = time.next()?.split('+').next()?.parse().ok()?; | |
| 118 | // Days from the civil date (Howard Hinnant's algorithm). | |
| 119 | let year = if month <= 2 { year - 1 } else { year }; | |
| 120 | let era = year.div_euclid(400); | |
| 121 | let year_of_era = year - era * 400; | |
| 122 | let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1; | |
| 123 | let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; | |
| 124 | let days = era * 146_097 + day_of_era - 719_468; | |
| 125 | let ms = ((days * 86_400 + hour * 3_600 + minute * 60) as f64 + second) * 1000.0; | |
| 126 | (ms >= 0.0).then_some(ms as u64) | |
| 127 | } | |
| 128 | ||
| 129 | /// A g1t repository name for a GitHub one. | |
| 130 | pub fn repo_name(github_name: &str) -> String { | |
| 131 | let name: String = github_name | |
| 132 | .trim() | |
| 133 | .to_lowercase() | |
| 134 | .chars() | |
| 135 | .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '-' }) | |
| 136 | .collect(); | |
| 137 | let name = name.trim_start_matches('.'); | |
| 138 | name.strip_suffix(".git").unwrap_or(name).chars().take(100).collect() | |
| 139 | } | |
| 140 | ||
| 141 | /// Whether a webhook delivery was signed with the app's secret. | |
| 142 | pub fn authentic(secret: &str, body: &str, headers: &HashMap<String, String>) -> bool { | |
| 143 | headers | |
| 144 | .get("x-hub-signature-256") | |
| 145 | .is_some_and(|signature| signature.trim().starts_with("sha256=") && crypto::signed(secret, body, signature)) | |
| 146 | } | |
| 147 | ||
| 148 | /// Labels as g1t keeps them: lowercase, at most 40 characters, ten each. | |
| 149 | pub fn labels_of(issue: &Value) -> Vec<String> { | |
| 150 | let mut labels: Vec<String> = Vec::new(); | |
| 151 | for label in issue["labels"].as_array().into_iter().flatten() { | |
| 152 | let name = label["name"].as_str().or(label.as_str()).unwrap_or_default().trim().to_lowercase(); | |
| 153 | if !name.is_empty() && name.chars().count() <= 40 && !labels.contains(&name) { | |
| 154 | labels.push(name); | |
| 155 | } | |
| 156 | } | |
| 157 | labels.truncate(10); | |
| 158 | labels | |
| 159 | } | |
| 160 | ||
| 161 | /// The opening of an imported issue's body: where it came from and who | |
| 162 | /// wrote it, by their g1t name when their GitHub account is linked. | |
| 163 | pub fn imported_header(issue: &Value, full_name: &str, g1t_name: Option<&str>) -> String { | |
| 164 | let login = issue["user"]["login"].as_str().unwrap_or("ghost"); | |
| 165 | let author = match g1t_name { | |
| 166 | Some(name) => format!("@{name} (@{login} on GitHub)"), | |
| 167 | None => format!("[@{login}](https://github.com/{login}) on GitHub"), | |
| 168 | }; | |
| 169 | let date = issue["created_at"].as_str().unwrap_or_default().get(..10).unwrap_or_default(); | |
| 170 | let mut header = format!( | |
| 171 | "> Imported from GitHub: [{full_name}#{}]({}), opened by {author}{}.", | |
| 172 | issue["number"], | |
| 173 | issue["html_url"].as_str().unwrap_or_default(), | |
| 174 | if date.is_empty() { String::new() } else { format!(" on {date}") }, | |
| 175 | ); | |
| 176 | if let Some(milestone) = issue["milestone"]["title"].as_str() { | |
| 177 | header.push_str(&format!("\n> Milestone: {milestone}")); | |
| 178 | } | |
| 179 | header | |
| 180 | } | |
| 181 | ||
| 182 | // --- The service -------------------------------------------------------------- | |
| 183 | ||
| 184 | #[derive(Deserialize)] | |
| 185 | struct InstallationRow { | |
| 186 | id: u64, | |
| 187 | workspace: String, | |
| 188 | account: String, | |
| 189 | account_type: String, | |
| 190 | repository_selection: String, | |
| 191 | settings_url: String, | |
| 192 | suspended_at: Option<String>, | |
| 193 | created_at: String, | |
| 194 | } | |
| 195 | ||
| 196 | impl From<InstallationRow> for GithubInstallation { | |
| 197 | fn from(row: InstallationRow) -> Self { | |
| 198 | GithubInstallation { | |
| 199 | id: row.id, | |
| 200 | workspace: row.workspace, | |
| 201 | account: row.account, | |
| 202 | account_type: row.account_type, | |
| 203 | repository_selection: row.repository_selection, | |
| 204 | suspended: row.suspended_at.is_some(), | |
| 205 | settings_url: row.settings_url, | |
| 206 | created_at: row.created_at, | |
| 207 | } | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 211 | #[derive(Deserialize)] | |
| 212 | struct LinkRow { | |
| 213 | repo_id: String, | |
| 214 | repo: String, | |
| 215 | installation_id: u64, | |
| 216 | github_repo_id: u64, | |
| 217 | full_name: String, | |
| 218 | mode: String, | |
| 219 | synced_at: Option<String>, | |
| 220 | last_error: Option<String>, | |
| 221 | issues_imported: u32, | |
| 222 | } | |
| 223 | ||
| 224 | impl From<LinkRow> for GithubRepoLink { | |
| 225 | fn from(row: LinkRow) -> Self { | |
| 226 | GithubRepoLink { | |
| 227 | repo_id: row.repo_id, | |
| 228 | repo: row.repo, | |
| 229 | installation_id: row.installation_id, | |
| 230 | github_repo_id: row.github_repo_id, | |
| 231 | full_name: row.full_name, | |
| 232 | mode: GithubMode::parse(&row.mode), | |
| 233 | synced_at: row.synced_at, | |
| 234 | last_error: row.last_error, | |
| 235 | issues_imported: row.issues_imported, | |
| 236 | } | |
| 237 | } | |
| 238 | } | |
| 239 | ||
| 240 | /// Issues to copy after an import has answered. | |
| 241 | pub struct IssueJob { | |
| 242 | actor: User, | |
| 243 | repo: RepoPath, | |
| 244 | repo_id: String, | |
| 245 | full_name: String, | |
| 246 | installation_id: u64, | |
| 247 | } | |
| 248 | ||
| 249 | pub struct GithubApp { | |
| 250 | db: D1Database, | |
| 251 | sealer: Option<Sealer>, | |
| 252 | identity: Fetcher, | |
| 253 | work: Fetcher, | |
| 254 | repos: Fetcher, | |
| 255 | config: AppConfig, | |
| 256 | } | |
| 257 | ||
| 258 | fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> { | |
| 259 | Outcome::fail(code, message) | |
| 260 | } | |
| 261 | ||
| 262 | /// Why `actor` may not do `capability` to a linked repository, if they may | |
| 263 | /// not. Without its visibility at hand, it is taken as private: whoever has | |
| 264 | /// no role on it is told it is not found, as for a private one, and the | |
| 265 | /// roles that act on it are never had through being public anyway. | |
| 266 | fn refused<T>(actor: &User, row: &LinkRow, capability: Capability) -> Option<Outcome<T>> { | |
| 267 | let namespace = row.repo.split('/').next().unwrap_or_default(); | |
| 268 | let target = access::RepoRef { id: &row.repo_id, namespace, private: true }; | |
| 269 | match access::check(Some(actor), target, capability) { | |
| 270 | Ok(()) => None, | |
| 271 | Err(access::Denied::NotFound) => Some(fail(FailureCode::NotFound, "Repository not found.")), | |
| 272 | Err(access::Denied::Forbidden) => Some(fail(FailureCode::Forbidden, access::needs(capability, &row.repo))), | |
| 273 | } | |
| 274 | } | |
| 275 | ||
| 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. | 276 | const SETTINGS_URL: &str = "https://github.com/settings/installations"; |
| 277 | ||
| 278 | /// One entry of GitHub's `GET /user/installations` (or an `installation` | |
| 279 | /// webhook's), as g1t shows it, with the workspaces it is recorded in. | |
| 280 | /// `None` without an id or an account. | |
| 281 | pub fn visible(item: &Value, recorded: &HashMap<u64, Vec<String>>) -> Option<GithubVisibleInstallation> { | |
| 282 | let id = item["id"].as_u64()?; | |
| 283 | let account = item["account"]["login"].as_str().filter(|login| !login.is_empty())?.to_owned(); | |
| 284 | Some(GithubVisibleInstallation { | |
| 285 | id, | |
| 286 | account, | |
| 287 | account_type: item["account"]["type"].as_str().or(item["target_type"].as_str()).unwrap_or("User").to_owned(), | |
| 288 | repository_selection: item["repository_selection"].as_str().unwrap_or("selected").to_owned(), | |
| 289 | suspended: item["suspended_at"].as_str().is_some(), | |
| 290 | settings_url: item["html_url"].as_str().unwrap_or(SETTINGS_URL).to_owned(), | |
| 291 | recorded_in: recorded.get(&id).cloned().unwrap_or_default(), | |
| 292 | }) | |
| 293 | } | |
| 294 | ||
| 295 | /// GitHub's listing as g1t shows it: by account, each once. | |
| 296 | pub fn visible_installations(listed: &[Value], recorded: &HashMap<u64, Vec<String>>) -> Vec<GithubVisibleInstallation> { | |
| 297 | let mut seen: Vec<GithubVisibleInstallation> = Vec::new(); | |
| 298 | for item in listed.iter().filter_map(|item| visible(item, recorded)) { | |
| 299 | if !seen.iter().any(|known| known.id == item.id) { | |
| 300 | seen.push(item); | |
| 301 | } | |
| 302 | } | |
| 303 | seen.sort_by_key(|item| item.account.to_lowercase()); | |
| 304 | seen | |
| 305 | } | |
| 306 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 307 | fn null_or(value: Option<&str>) -> JsValue { |
| 308 | value.map_or(JsValue::NULL, Into::into) | |
| 309 | } | |
| 310 | ||
| 311 | fn number(value: u64) -> JsValue { | |
| 312 | (value as f64).into() | |
| 313 | } | |
| 314 | ||
| 315 | const NOT_SET_UP: &str = "GitHub is not set up on this g1t."; | |
| 316 | ||
| 317 | impl GithubApp { | |
| 318 | pub fn new(env: &Env) -> Result<Self> { | |
| 319 | Ok(GithubApp { | |
| 320 | db: env.d1("DB")?, | |
| 321 | sealer: env.secret("INTEGRATIONS_KEY").ok().and_then(|key| Sealer::new(&key.to_string())), | |
| 322 | identity: env.service("IDENTITY")?, | |
| 323 | work: env.service("WORK")?, | |
| 324 | repos: env.service("REPOS")?, | |
| 325 | config: AppConfig::from_env(env), | |
| 326 | }) | |
| 327 | } | |
| 328 | ||
| Merge branch 'mirroring' into artifacts-mode | 329 | /// GitHub's REST API, with an installation or user token. |
| 330 | pub(crate) async fn api(&self, method: Method, path: &str, token: &str, body: Option<Value>) -> Result<Answer> { | |
| 331 | self.github(method, path, token, body).await | |
| 332 | } | |
| 333 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 334 | async fn github(&self, method: Method, path: &str, token: &str, body: Option<Value>) -> Result<Answer> { |
| 335 | let authorization = format!("Bearer {token}"); | |
| 336 | let url = if path.starts_with("https://") { path.to_owned() } else { format!("{API}{path}") }; | |
| 337 | http::send( | |
| 338 | method, | |
| 339 | &url, | |
| 340 | &[ | |
| 341 | ("authorization", &authorization), | |
| 342 | ("accept", "application/vnd.github+json"), | |
| 343 | ("x-github-api-version", "2022-11-28"), | |
| 344 | ], | |
| 345 | body.map(|body| body.to_string()), | |
| 346 | ) | |
| 347 | .await | |
| 348 | } | |
| 349 | ||
| 350 | /// The person's GitHub user token, from identity. | |
| 351 | async fn user_token(&self, user: &User) -> Result<Outcome<String>> { | |
| 352 | g1t_kit::call(&self.identity, "github_user_token", &GithubUserTokenArgs { user_id: user.id.clone() }).await | |
| 353 | } | |
| 354 | ||
| 355 | /// An installation access token, from the cache or fresh from GitHub. | |
| Merge branch 'mirroring' into artifacts-mode | 356 | pub(crate) async fn installation_token(&self, installation_id: u64) -> Result<std::result::Result<String, String>> { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 357 | #[derive(Deserialize)] |
| 358 | struct Cached { | |
| 359 | token: String, | |
| 360 | expires_ms: f64, | |
| 361 | } | |
| 362 | let bound = format!("ghi:{installation_id}"); | |
| 363 | let now = now_ms(); | |
| 364 | let cached = self | |
| 365 | .db | |
| 366 | .prepare("SELECT token, expires_ms FROM github_tokens WHERE installation_id = ?") | |
| 367 | .bind(&[number(installation_id)])? | |
| 368 | .first::<Cached>(None) | |
| 369 | .await?; | |
| 370 | if let (Some(cached), Some(sealer)) = (cached, &self.sealer) | |
| 371 | && cached.expires_ms as u64 > now + TOKEN_MARGIN_MS | |
| 372 | && let Some(token) = sealer.open(&cached.token, &bound) | |
| 373 | { | |
| 374 | return Ok(Ok(token)); | |
| 375 | } | |
| 376 | let Some(key) = self.config.private_key.as_deref().filter(|_| self.config.configured()) else { | |
| 377 | return Ok(Err(NOT_SET_UP.to_owned())); | |
| 378 | }; | |
| 379 | let jwt = github_jwt::app_jwt(self.config.issuer(), key, now / 1000).await?; | |
| 380 | let answer = self | |
| 381 | .github(Method::Post, &format!("/app/installations/{installation_id}/access_tokens"), &jwt, None) | |
| 382 | .await?; | |
| 383 | if !answer.ok() { | |
| 384 | return Ok(Err(answer.problem("GitHub"))); | |
| 385 | } | |
| 386 | let body = answer.json(); | |
| 387 | let Some(token) = body["token"].as_str().filter(|token| !token.is_empty()).map(str::to_owned) else { | |
| 388 | return Ok(Err("GitHub sent no installation token.".to_owned())); | |
| 389 | }; | |
| 390 | // GitHub's tokens last an hour; trust its own expiry when it says. | |
| 391 | let expires = body["expires_at"].as_str().and_then(parse_time).unwrap_or(now + 60 * 60 * 1000); | |
| 392 | if let Some(sealer) = &self.sealer { | |
| 393 | self.db | |
| 394 | .prepare( | |
| 395 | "INSERT INTO github_tokens (installation_id, token, expires_ms) VALUES (?1, ?2, ?3) | |
| 396 | ON CONFLICT (installation_id) DO UPDATE SET token = excluded.token, expires_ms = excluded.expires_ms", | |
| 397 | ) | |
| 398 | .bind(&[number(installation_id), sealer.seal(&token, &bound).into(), (expires as f64).into()])? | |
| 399 | .run() | |
| 400 | .await?; | |
| 401 | } | |
| 402 | Ok(Ok(token)) | |
| 403 | } | |
| 404 | ||
| 405 | async fn installation(&self, workspace: &str, id: u64) -> Result<Option<InstallationRow>> { | |
| 406 | self.db | |
| 407 | .prepare("SELECT * FROM github_installations WHERE workspace = ? AND id = ?") | |
| 408 | .bind(&[workspace.into(), number(id)])? | |
| 409 | .first::<InstallationRow>(None) | |
| 410 | .await | |
| 411 | } | |
| 412 | ||
| 413 | async fn link(&self, repo_id: &str) -> Result<Option<LinkRow>> { | |
| 414 | self.db | |
| 415 | .prepare("SELECT * FROM github_repos WHERE repo_id = ?") | |
| 416 | .bind(&[repo_id.into()])? | |
| 417 | .first::<LinkRow>(None) | |
| 418 | .await | |
| 419 | } | |
| 420 | ||
| 421 | async fn note(&self, repo_id: &str, problem: Option<&str>) -> Result<()> { | |
| 422 | let sql = if problem.is_some() { | |
| 423 | "UPDATE github_repos SET last_error = ?2 WHERE repo_id = ?1" | |
| 424 | } else { | |
| 425 | "UPDATE github_repos SET last_error = ?2, synced_at = ?3 WHERE repo_id = ?1" | |
| 426 | }; | |
| 427 | let statement = self.db.prepare(sql); | |
| 428 | let statement = if problem.is_some() { | |
| 429 | statement.bind(&[repo_id.into(), null_or(problem)])? | |
| 430 | } else { | |
| 431 | statement.bind(&[repo_id.into(), JsValue::NULL, rfc3339(now_ms()).into()])? | |
| 432 | }; | |
| 433 | statement.run().await?; | |
| 434 | Ok(()) | |
| 435 | } | |
| 436 | ||
| 437 | pub async fn status(&self, a: GithubStatusArgs) -> Result<Outcome<GithubAppStatus>> { | |
| 438 | let workspace = a.workspace.to_lowercase(); | |
| 439 | if !a.viewer.is_member(&workspace) { | |
| 440 | return Ok(fail(FailureCode::Forbidden, "Only members of the workspace can see its GitHub installations.")); | |
| 441 | } | |
| 442 | if !self.config.configured() { | |
| 443 | return Ok(Outcome::Ok(GithubAppStatus::default())); | |
| 444 | } | |
| 445 | let account: GithubAccountView = | |
| 446 | g1t_kit::call(&self.identity, "github_account", &json!({ "user": a.viewer })).await?; | |
| 447 | let installations = self | |
| 448 | .db | |
| 449 | .prepare("SELECT * FROM github_installations WHERE workspace = ? ORDER BY account") | |
| 450 | .bind(&[workspace.as_str().into()])? | |
| 451 | .all() | |
| 452 | .await? | |
| 453 | .results::<InstallationRow>()?; | |
| 454 | Ok(Outcome::Ok(GithubAppStatus { | |
| 455 | configured: true, | |
| 456 | install_url: Some(self.config.install_url()), | |
| 457 | linked: account.account.is_some_and(|account| account.authorized), | |
| 458 | installations: installations.into_iter().map(Into::into).collect(), | |
| 459 | })) | |
| 460 | } | |
| 461 | ||
| 462 | fn owner_only<T>(actor: &User, workspace: &str) -> Option<Outcome<T>> { | |
| 463 | (actor.role_in(workspace) != Some(Role::Owner) || actor.kind != PrincipalKind::User).then(|| { | |
| 464 | fail(FailureCode::Forbidden, "Only an owner of the workspace can add or remove GitHub accounts.") | |
| 465 | }) | |
| 466 | } | |
| 467 | ||
| 468 | /// Records an installation against a workspace, once the person's own | |
| 469 | /// GitHub token shows it is one they can see. | |
| 470 | pub async fn add_installation(&self, a: GithubInstallationArgs) -> Result<Outcome<GithubInstallation>> { | |
| 471 | let workspace = a.workspace.to_lowercase(); | |
| 472 | if let Some(refused) = Self::owner_only(&a.actor, &workspace) { | |
| 473 | return Ok(refused); | |
| 474 | } | |
| 475 | if !self.config.configured() { | |
| 476 | return Ok(fail(FailureCode::NotFound, NOT_SET_UP)); | |
| 477 | } | |
| 478 | let token = match self.user_token(&a.actor).await? { | |
| 479 | Outcome::Ok(token) => token, | |
| 480 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 481 | }; | |
| 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. | 482 | let listed = match self.user_installations(&token).await? { |
| 483 | Ok(listed) => listed, | |
| 484 | Err(problem) => return Ok(fail(FailureCode::Conflict, problem)), | |
| 485 | }; | |
| 486 | let Some(item) = listed.into_iter().find(|item| item["id"].as_u64() == Some(a.installation_id)) else { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 487 | return Ok(fail( |
| 488 | FailureCode::Forbidden, | |
| 489 | "Your GitHub account cannot see that installation. Install the app from your own GitHub account or an organization you manage.", | |
| 490 | )); | |
| 491 | }; | |
| 492 | let now = rfc3339(now_ms()); | |
| 493 | let row = InstallationRow { | |
| 494 | id: a.installation_id, | |
| 495 | workspace: workspace.clone(), | |
| 496 | account: item["account"]["login"].as_str().unwrap_or_default().to_owned(), | |
| 497 | account_type: item["account"]["type"].as_str().or(item["target_type"].as_str()).unwrap_or("User").to_owned(), | |
| 498 | repository_selection: item["repository_selection"].as_str().unwrap_or("selected").to_owned(), | |
| 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. | 499 | settings_url: item["html_url"].as_str().unwrap_or(SETTINGS_URL).to_owned(), |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 500 | suspended_at: item["suspended_at"].as_str().map(str::to_owned), |
| 501 | created_at: now, | |
| 502 | }; | |
| 503 | self.db | |
| 504 | .prepare( | |
| 505 | "INSERT INTO github_installations | |
| 506 | (id, workspace, account, account_type, repository_selection, settings_url, suspended_at, added_by, created_at) | |
| 507 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) | |
| 508 | ON CONFLICT (id, workspace) DO UPDATE SET account = excluded.account, | |
| 509 | repository_selection = excluded.repository_selection, settings_url = excluded.settings_url, | |
| 510 | suspended_at = excluded.suspended_at", | |
| 511 | ) | |
| 512 | .bind(&[ | |
| 513 | number(row.id), | |
| 514 | row.workspace.as_str().into(), | |
| 515 | row.account.as_str().into(), | |
| 516 | row.account_type.as_str().into(), | |
| 517 | row.repository_selection.as_str().into(), | |
| 518 | row.settings_url.as_str().into(), | |
| 519 | null_or(row.suspended_at.as_deref()), | |
| 520 | a.actor.id.as_str().into(), | |
| 521 | row.created_at.as_str().into(), | |
| 522 | ])? | |
| 523 | .run() | |
| 524 | .await?; | |
| 525 | Ok(Outcome::Ok(row.into())) | |
| 526 | } | |
| 527 | ||
| 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. | 528 | /// Every installation of the app the person's GitHub user token can |
| 529 | /// see: on their own account, and on organizations they belong to. | |
| 530 | async fn user_installations(&self, token: &str) -> Result<std::result::Result<Vec<Value>, String>> { | |
| 531 | let mut all = Vec::new(); | |
| 532 | for page in 1..=5 { | |
| 533 | let answer = self | |
| 534 | .github(Method::Get, &format!("/user/installations?per_page=100&page={page}"), token, None) | |
| 535 | .await?; | |
| 536 | if !answer.ok() { | |
| 537 | return Ok(Err(answer.problem("GitHub"))); | |
| 538 | } | |
| 539 | let listed = answer.json()["installations"].as_array().cloned().unwrap_or_default(); | |
| 540 | let more = listed.len() == 100; | |
| 541 | all.extend(listed); | |
| 542 | if !more { | |
| 543 | break; | |
| 544 | } | |
| 545 | } | |
| 546 | Ok(Ok(all)) | |
| 547 | } | |
| 548 | ||
| 549 | /// The app's installations the person can see on GitHub, each with the | |
| 550 | /// workspaces of theirs it is recorded in, so one installed on GitHub | |
| 551 | /// directly can be added to a workspace. | |
| 552 | pub async fn visible_installations(&self, a: GithubVisibleArgs) -> Result<Outcome<Vec<GithubVisibleInstallation>>> { | |
| 553 | if a.actor.kind != PrincipalKind::User { | |
| 554 | return Ok(fail(FailureCode::Forbidden, "Only a person can see their GitHub installations.")); | |
| 555 | } | |
| 556 | if !self.config.configured() { | |
| 557 | return Ok(fail(FailureCode::NotFound, NOT_SET_UP)); | |
| 558 | } | |
| 559 | let token = match self.user_token(&a.actor).await? { | |
| 560 | Outcome::Ok(token) => token, | |
| 561 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 562 | }; | |
| 563 | let listed = match self.user_installations(&token).await? { | |
| 564 | Ok(listed) => listed, | |
| 565 | Err(problem) => return Ok(fail(FailureCode::Conflict, problem)), | |
| 566 | }; | |
| 567 | let ids: Vec<u64> = listed.iter().filter_map(|item| item["id"].as_u64()).collect(); | |
| 568 | let mut recorded: HashMap<u64, Vec<String>> = HashMap::new(); | |
| 569 | if !ids.is_empty() { | |
| 570 | #[derive(Deserialize)] | |
| 571 | struct Recorded { | |
| 572 | id: u64, | |
| 573 | workspace: String, | |
| 574 | } | |
| 575 | let marks = vec!["?"; ids.len()].join(", "); | |
| 576 | let bind: Vec<JsValue> = ids.iter().map(|id| number(*id)).collect(); | |
| 577 | let rows = self | |
| 578 | .db | |
| 579 | .prepare(format!("SELECT id, workspace FROM github_installations WHERE id IN ({marks}) ORDER BY workspace")) | |
| 580 | .bind(&bind)? | |
| 581 | .all() | |
| 582 | .await? | |
| 583 | .results::<Recorded>()?; | |
| 584 | // Only the person's own workspaces are named back to them. | |
| 585 | for row in rows.into_iter().filter(|row| a.actor.is_member(&row.workspace)) { | |
| 586 | recorded.entry(row.id).or_default().push(row.workspace); | |
| 587 | } | |
| 588 | } | |
| 589 | Ok(Outcome::Ok(visible_installations(&listed, &recorded))) | |
| 590 | } | |
| 591 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 592 | /// Forgets an installation in a workspace; its mirrors stop. The app |
| 593 | /// stays installed on GitHub until it is uninstalled there. | |
| Merge branch 'mirroring' into artifacts-mode | 594 | pub async fn remove_installation(&self, a: GithubInstallationArgs, mirrors: Option<&crate::remotes::Mirrors>) -> Result<Outcome<bool>> { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 595 | let workspace = a.workspace.to_lowercase(); |
| 596 | if let Some(refused) = Self::owner_only(&a.actor, &workspace) { | |
| 597 | return Ok(refused); | |
| 598 | } | |
| 599 | self.db | |
| 600 | .prepare("DELETE FROM github_installations WHERE workspace = ? AND id = ?") | |
| 601 | .bind(&[workspace.as_str().into(), number(a.installation_id)])? | |
| 602 | .run() | |
| 603 | .await?; | |
| 604 | self.db | |
| 605 | .prepare("UPDATE github_repos SET mode = 'import' WHERE workspace = ? AND installation_id = ?") | |
| 606 | .bind(&[workspace.as_str().into(), number(a.installation_id)])? | |
| 607 | .run() | |
| 608 | .await?; | |
| Merge branch 'mirroring' into artifacts-mode | 609 | if let Some(mirrors) = mirrors { |
| 610 | let rows = self | |
| 611 | .db | |
| 612 | .prepare("SELECT id FROM remotes WHERE provider = 'github' AND workspace = ? AND connection_id = ?") | |
| 613 | .bind(&[workspace.as_str().into(), a.installation_id.to_string().into()])? | |
| 614 | .all() | |
| 615 | .await? | |
| 616 | .results::<Value>()?; | |
| 617 | for id in rows.iter().filter_map(|row| row["id"].as_str()) { | |
| 618 | mirrors.link_gone(id, "lost its GitHub account in this workspace").await?; | |
| 619 | } | |
| 620 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 621 | Ok(Outcome::Ok(true)) |
| 622 | } | |
| 623 | ||
| 624 | pub async fn repositories(&self, a: GithubRepositoriesArgs) -> Result<Outcome<GithubRepositories>> { | |
| 625 | let workspace = a.workspace.to_lowercase(); | |
| 626 | if !a.actor.is_member(&workspace) { | |
| 627 | return Ok(fail(FailureCode::Forbidden, "Only members of the workspace can bring repositories into it.")); | |
| 628 | } | |
| 629 | if self.installation(&workspace, a.installation_id).await?.is_none() { | |
| 630 | return Ok(fail(FailureCode::NotFound, "That GitHub account is not connected to this workspace.")); | |
| 631 | } | |
| 632 | let token = match self.user_token(&a.actor).await? { | |
| 633 | Outcome::Ok(token) => token, | |
| 634 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 635 | }; | |
| 636 | let page = a.page.unwrap_or(1).max(1); | |
| 637 | let answer = self | |
| 638 | .github( | |
| 639 | Method::Get, | |
| 640 | &format!("/user/installations/{}/repositories?per_page={PER_PAGE}&page={page}", a.installation_id), | |
| 641 | &token, | |
| 642 | None, | |
| 643 | ) | |
| 644 | .await?; | |
| 645 | if !answer.ok() { | |
| 646 | return Ok(fail(FailureCode::Conflict, answer.problem("GitHub"))); | |
| 647 | } | |
| 648 | let body = answer.json(); | |
| 649 | let listed = body["repositories"].as_array().cloned().unwrap_or_default(); | |
| 650 | let ids: Vec<u64> = listed.iter().filter_map(|repo| repo["id"].as_u64()).collect(); | |
| 651 | let mut linked = HashMap::new(); | |
| 652 | if !ids.is_empty() { | |
| 653 | #[derive(Deserialize)] | |
| 654 | struct Linked { | |
| 655 | github_repo_id: u64, | |
| 656 | repo: String, | |
| 657 | } | |
| 658 | let marks = vec!["?"; ids.len()].join(", "); | |
| 659 | let mut bind = vec![JsValue::from(workspace.as_str())]; | |
| 660 | bind.extend(ids.iter().map(|id| number(*id))); | |
| 661 | let rows = self | |
| 662 | .db | |
| 663 | .prepare(format!( | |
| 664 | "SELECT github_repo_id, repo FROM github_repos WHERE workspace = ? AND github_repo_id IN ({marks})" | |
| 665 | )) | |
| 666 | .bind(&bind)? | |
| 667 | .all() | |
| 668 | .await? | |
| 669 | .results::<Linked>()?; | |
| 670 | linked = rows.into_iter().map(|row| (row.github_repo_id, row.repo)).collect(); | |
| 671 | } | |
| 672 | Ok(Outcome::Ok(GithubRepositories { | |
| 673 | repositories: listed | |
| 674 | .iter() | |
| 675 | .filter_map(|repo| { | |
| 676 | let id = repo["id"].as_u64()?; | |
| 677 | Some(GithubRepository { | |
| 678 | id, | |
| 679 | full_name: repo["full_name"].as_str()?.to_owned(), | |
| 680 | name: repo["name"].as_str()?.to_owned(), | |
| 681 | private: repo["private"].as_bool().unwrap_or(true), | |
| 682 | description: repo["description"].as_str().map(str::to_owned), | |
| 683 | default_branch: repo["default_branch"].as_str().unwrap_or("main").to_owned(), | |
| 684 | linked_to: linked.get(&id).cloned(), | |
| 685 | }) | |
| 686 | }) | |
| 687 | .collect(), | |
| 688 | total: body["total_count"].as_u64().unwrap_or(listed.len() as u64) as u32, | |
| 689 | page, | |
| 690 | per_page: PER_PAGE, | |
| 691 | })) | |
| 692 | } | |
| 693 | ||
| 694 | /// Brings one GitHub repository across. Returns the issues still to | |
| 695 | /// copy, which the caller does after answering. | |
| 696 | pub async fn import(&self, a: GithubImportArgs) -> Result<(Outcome<GithubRepoLink>, Option<IssueJob>)> { | |
| 697 | let workspace = a.workspace.to_lowercase(); | |
| 698 | let refuse = |code, message: &str| Ok((fail(code, message), None)); | |
| 699 | if !a.actor.is_member(&workspace) { | |
| 700 | return refuse(FailureCode::Forbidden, "Only members of the workspace can bring repositories into it."); | |
| 701 | } | |
| 702 | if self.installation(&workspace, a.installation_id).await?.is_none() { | |
| 703 | return refuse(FailureCode::NotFound, "That GitHub account is not connected to this workspace."); | |
| 704 | } | |
| 705 | let user_token = match self.user_token(&a.actor).await? { | |
| 706 | Outcome::Ok(token) => token, | |
| 707 | Outcome::Fail(failure) => return Ok((Outcome::Fail(failure), None)), | |
| 708 | }; | |
| 709 | // Read with the person's token: only a repository both they and the | |
| 710 | // installation can reach answers. | |
| 711 | let answer = self | |
| 712 | .github(Method::Get, &format!("/repositories/{}", a.github_repo_id), &user_token, None) | |
| 713 | .await?; | |
| 714 | if !answer.ok() { | |
| 715 | return refuse(FailureCode::NotFound, "That GitHub repository is not one you and the app can both reach."); | |
| 716 | } | |
| 717 | let github = answer.json(); | |
| 718 | let (Some(full_name), Some(clone_url)) = (github["full_name"].as_str(), github["clone_url"].as_str()) else { | |
| 719 | return refuse(FailureCode::Conflict, "GitHub did not describe the repository."); | |
| 720 | }; | |
| 721 | let name = a | |
| 722 | .name | |
| 723 | .as_deref() | |
| 724 | .map(str::trim) | |
| 725 | .filter(|name| !name.is_empty()) | |
| 726 | .map(str::to_lowercase) | |
| 727 | .unwrap_or_else(|| repo_name(github["name"].as_str().unwrap_or_default())); | |
| 728 | if !is_valid_repo_name(&name) { | |
| 729 | return refuse(FailureCode::Invalid, "Use letters, digits, dots, hyphens and underscores only."); | |
| 730 | } | |
| 731 | let token = match self.installation_token(a.installation_id).await? { | |
| 732 | Ok(token) => token, | |
| 733 | Err(reason) => return refuse(FailureCode::Conflict, &reason), | |
| 734 | }; | |
| 735 | let created: Outcome<Repo> = g1t_kit::call( | |
| 736 | &self.repos, | |
| 737 | "create", | |
| 738 | &CreateArgs { | |
| 739 | owner: a.actor.clone(), | |
| 740 | namespace: workspace.clone(), | |
| 741 | name, | |
| 742 | description: github["description"].as_str().map(|text| text.chars().take(200).collect()), | |
| 743 | is_private: a.private.unwrap_or_else(|| github["private"].as_bool().unwrap_or(true)), | |
| 744 | import_url: Some(clone_url.to_owned()), | |
| 745 | import_token: Some(token), | |
| Merge branch 'mirroring' into artifacts-mode | 746 | // A mirror is read-only from the start. |
| 747 | mirror: (a.mode == GithubMode::Mirror).then(|| RepoMirror { | |
| 748 | state: MirrorState::Standby, | |
| 749 | remote: format!("github.com/{full_name}"), | |
| 750 | url: format!("https://github.com/{full_name}"), | |
| 751 | since: rfc3339(now_ms()), | |
| 752 | warm: false, | |
| 753 | github_workflows: true, | |
| 754 | hold_deploys: true, | |
| 755 | }), | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 756 | }, |
| 757 | ) | |
| 758 | .await?; | |
| 759 | let repo = match created { | |
| 760 | Outcome::Ok(repo) => repo, | |
| 761 | Outcome::Fail(failure) => return Ok((Outcome::Fail(failure), None)), | |
| 762 | }; | |
| 763 | let path = format!("{}/{}", repo.namespace, repo.name); | |
| 764 | let now = rfc3339(now_ms()); | |
| 765 | self.db | |
| 766 | .prepare( | |
| 767 | "INSERT INTO github_repos | |
| 768 | (repo_id, workspace, repo, installation_id, github_repo_id, full_name, mode, synced_at, created_by, created_at) | |
| 769 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?8)", | |
| 770 | ) | |
| 771 | .bind(&[ | |
| 772 | repo.id.as_str().into(), | |
| 773 | workspace.as_str().into(), | |
| 774 | path.as_str().into(), | |
| 775 | number(a.installation_id), | |
| 776 | number(a.github_repo_id), | |
| 777 | full_name.into(), | |
| 778 | a.mode.as_str().into(), | |
| 779 | now.as_str().into(), | |
| 780 | a.actor.id.as_str().into(), | |
| 781 | ])? | |
| 782 | .run() | |
| 783 | .await?; | |
| Merge branch 'mirroring' into artifacts-mode | 784 | if a.mode != GithubMode::Import { |
| 785 | let (role, state) = if a.mode == GithubMode::Mirror { ("leader", "standby") } else { ("follower", "following") }; | |
| 786 | self.db | |
| 787 | .prepare( | |
| 788 | "INSERT INTO remotes | |
| 789 | (id, repo_id, workspace, repo, provider, role, name, url, clone_url, external_id, connection_id, | |
| 790 | state, state_since, state_by, settings, recorded, synced_at, created_by, created_at) | |
| 791 | VALUES (?1, ?2, ?3, ?4, 'github', ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, '{}', 1, ?12, ?14, ?12)", | |
| 792 | ) | |
| 793 | .bind(&[ | |
| 794 | g1t_contracts::new_id("rmt", now_ms()).into(), | |
| 795 | repo.id.as_str().into(), | |
| 796 | workspace.as_str().into(), | |
| 797 | path.as_str().into(), | |
| 798 | role.into(), | |
| 799 | format!("github.com/{full_name}").into(), | |
| 800 | format!("https://github.com/{full_name}").into(), | |
| 801 | format!("https://github.com/{full_name}.git").into(), | |
| 802 | a.github_repo_id.to_string().into(), | |
| 803 | a.installation_id.to_string().into(), | |
| 804 | state.into(), | |
| 805 | now.as_str().into(), | |
| 806 | a.actor.username.as_str().into(), | |
| 807 | a.actor.id.as_str().into(), | |
| 808 | ])? | |
| 809 | .run() | |
| 810 | .await?; | |
| 811 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 812 | let job = a.issues.then(|| IssueJob { |
| 813 | actor: a.actor.clone(), | |
| 814 | repo: RepoPath { | |
| 815 | namespace: repo.namespace.clone(), | |
| 816 | name: repo.name.clone(), | |
| 817 | }, | |
| 818 | repo_id: repo.id.clone(), | |
| 819 | full_name: full_name.to_owned(), | |
| 820 | installation_id: a.installation_id, | |
| 821 | }); | |
| 822 | let link = self.link(&repo.id).await?.map(GithubRepoLink::from); | |
| 823 | Ok((link.map_or_else(|| fail(FailureCode::NotFound, "The link was not kept."), Outcome::Ok), job)) | |
| 824 | } | |
| 825 | ||
| 826 | /// Copies a repository's issues, oldest first, with their labels, | |
| 827 | /// milestone and state. Pull requests are left: their branches came | |
| 828 | /// with the git data. | |
| 829 | pub async fn copy_issues(&self, job: IssueJob) -> Result<()> { | |
| 830 | let token = match self.installation_token(job.installation_id).await? { | |
| 831 | Ok(token) => token, | |
| 832 | Err(reason) => return self.note(&job.repo_id, Some(&format!("Issues were not copied: {reason}"))).await, | |
| 833 | }; | |
| 834 | let mut issues: Vec<Value> = Vec::new(); | |
| 835 | let mut more = false; | |
| 836 | for page in 1..=4 { | |
| 837 | let answer = self | |
| 838 | .github( | |
| 839 | Method::Get, | |
| 840 | &format!("/repos/{}/issues?state=all&sort=created&direction=asc&per_page=100&page={page}", job.full_name), | |
| 841 | &token, | |
| 842 | None, | |
| 843 | ) | |
| 844 | .await?; | |
| 845 | if !answer.ok() { | |
| 846 | return self.note(&job.repo_id, Some(&format!("Issues were not copied: {}", answer.problem("GitHub")))).await; | |
| 847 | } | |
| 848 | let listed = answer.json().as_array().cloned().unwrap_or_default(); | |
| 849 | let full = listed.len() == 100; | |
| 850 | issues.extend(listed.into_iter().filter(|issue| issue.get("pull_request").is_none())); | |
| 851 | if issues.len() >= MAX_ISSUES { | |
| 852 | more = issues.len() > MAX_ISSUES || full; | |
| 853 | issues.truncate(MAX_ISSUES); | |
| 854 | break; | |
| 855 | } | |
| 856 | if !full { | |
| 857 | break; | |
| 858 | } | |
| 859 | } | |
| 860 | let authors: HashSet<u64> = issues.iter().filter_map(|issue| issue["user"]["id"].as_u64()).collect(); | |
| 861 | let names: HashMap<String, String> = g1t_kit::call( | |
| 862 | &self.identity, | |
| 863 | "github_usernames", | |
| 864 | &GithubUsernamesArgs { | |
| 865 | github_ids: authors.into_iter().collect(), | |
| 866 | }, | |
| 867 | ) | |
| 868 | .await | |
| 869 | .unwrap_or_default(); | |
| 870 | let mut copied = 0u32; | |
| 871 | for issue in &issues { | |
| 872 | let g1t_name = issue["user"]["id"].as_u64().and_then(|id| names.get(&id.to_string())).map(String::as_str); | |
| 873 | let mut body = imported_header(issue, &job.full_name, g1t_name); | |
| 874 | if let Some(text) = issue["body"].as_str().filter(|text| !text.trim().is_empty()) { | |
| 875 | body.push_str("\n\n"); | |
| 876 | body.push_str(&http::shorten(text.trim(), 60_000)); | |
| 877 | } | |
| 878 | let opened: Outcome<Issue> = g1t_kit::call( | |
| 879 | &self.work, | |
| 880 | "open_issue", | |
| 881 | &OpenIssueArgs { | |
| 882 | actor: job.actor.clone(), | |
| 883 | repo: job.repo.clone(), | |
| 884 | title: issue["title"].as_str().unwrap_or("Untitled").chars().take(200).collect(), | |
| 885 | body, | |
| 886 | labels: labels_of(issue), | |
| 887 | checks: Vec::new(), | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 888 | milestone: None, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 889 | }, |
| 890 | ) | |
| 891 | .await?; | |
| 892 | let Outcome::Ok(opened) = opened else { continue }; | |
| 893 | if issue["state"].as_str() == Some("closed") { | |
| 894 | let reason = if issue["state_reason"].as_str() == Some("not_planned") { | |
| 895 | IssueReason::NotPlanned | |
| 896 | } else { | |
| 897 | IssueReason::Completed | |
| 898 | }; | |
| 899 | let _: Outcome<Value> = g1t_kit::call( | |
| 900 | &self.work, | |
| 901 | "close_issue", | |
| 902 | &IssueActionArgs { | |
| 903 | actor: job.actor.clone(), | |
| 904 | repo: job.repo.clone(), | |
| 905 | number: opened.number, | |
| 906 | reason: Some(reason), | |
| 907 | }, | |
| 908 | ) | |
| 909 | .await?; | |
| 910 | } | |
| 911 | copied += 1; | |
| 912 | } | |
| 913 | self.db | |
| 914 | .prepare("UPDATE github_repos SET issues_imported = ? WHERE repo_id = ?") | |
| 915 | .bind(&[copied.into(), job.repo_id.as_str().into()])? | |
| 916 | .run() | |
| 917 | .await?; | |
| 918 | if more { | |
| 919 | self.note(&job.repo_id, Some(&format!("Copied the first {MAX_ISSUES} issues; the rest stay on GitHub."))) | |
| 920 | .await?; | |
| 921 | } | |
| 922 | Ok(()) | |
| 923 | } | |
| 924 | ||
| 925 | pub async fn link_for(&self, a: GithubLinkArgs) -> Result<Option<GithubRepoLink>> { | |
| Merge branch 'mirroring' into artifacts-mode | 926 | let Some(row) = self.link(&a.repo_id).await? else { return Ok(None) }; |
| 927 | // What the repository is now is the remote's to say. | |
| 928 | let role = self | |
| 929 | .db | |
| 930 | .prepare("SELECT role FROM remotes WHERE repo_id = ? AND provider = 'github' AND external_id = ?") | |
| 931 | .bind(&[a.repo_id.as_str().into(), row.github_repo_id.to_string().into()])? | |
| 932 | .first::<String>(Some("role")) | |
| 933 | .await?; | |
| 934 | let mut link: GithubRepoLink = row.into(); | |
| 935 | link.mode = match role.as_deref() { | |
| 936 | Some("leader") => GithubMode::Mirror, | |
| 937 | Some("follower") => GithubMode::Push, | |
| 938 | _ => GithubMode::Import, | |
| 939 | }; | |
| 940 | Ok(Some(link)) | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 941 | } |
| 942 | ||
| Merge branch 'mirroring' into artifacts-mode | 943 | /// Stops a link to GitHub: the g1t repository keeps what it has and is |
| 944 | /// its own. Refused during a takeover (see `remotes.rs`). | |
| 945 | pub async fn unlink_repo(&self, a: GithubUnlinkRepoArgs, env: &Env) -> Result<Outcome<bool>> { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 946 | let Some(row) = self.link(&a.repo_id).await? else { |
| 947 | return Ok(Outcome::Ok(false)); | |
| 948 | }; | |
| 949 | if let Some(refused) = refused(&a.actor, &row, Capability::ManageIntegrations) { | |
| 950 | return Ok(refused); | |
| 951 | } | |
| Merge branch 'mirroring' into artifacts-mode | 952 | let ids = self |
| 953 | .db | |
| 954 | .prepare("SELECT id FROM remotes WHERE repo_id = ? AND provider = 'github'") | |
| 955 | .bind(&[a.repo_id.as_str().into()])? | |
| 956 | .all() | |
| 957 | .await? | |
| 958 | .results::<Value>()?; | |
| 959 | let mirrors = crate::remotes::Mirrors::new(env)?; | |
| 960 | for id in ids.iter().filter_map(|row| row["id"].as_str()) { | |
| 961 | let removed = mirrors | |
| 962 | .remove(g1t_contracts::mirrors::MirrorRemoveArgs { actor: a.actor.clone(), remote_id: id.to_owned() }) | |
| 963 | .await?; | |
| 964 | if let Outcome::Fail(failure) = removed { | |
| 965 | return Ok(Outcome::Fail(failure)); | |
| 966 | } | |
| 967 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 968 | self.db |
| 969 | .prepare("UPDATE github_repos SET mode = 'import' WHERE repo_id = ?") | |
| 970 | .bind(&[a.repo_id.as_str().into()])? | |
| 971 | .run() | |
| 972 | .await?; | |
| 973 | Ok(Outcome::Ok(true)) | |
| 974 | } | |
| 975 | ||
| Merge branch 'mirroring' into artifacts-mode | 976 | pub async fn sync_now(&self, a: GithubUnlinkRepoArgs, env: &Env) -> Result<Outcome<GithubRepoLink>> { |
| 977 | if self.link(&a.repo_id).await?.is_none() { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 978 | return Ok(fail(FailureCode::NotFound, "This repository is not linked to GitHub.")); |
| 979 | } | |
| Merge branch 'mirroring' into artifacts-mode | 980 | let synced = crate::remotes::Mirrors::new(env)? |
| 981 | .sync_now(MirrorActArgs { actor: a.actor.clone(), repo_id: a.repo_id.clone() }) | |
| 982 | .await?; | |
| 983 | if let Outcome::Fail(failure) = synced { | |
| 984 | return Ok(Outcome::Fail(failure)); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 985 | } |
| Merge branch 'mirroring' into artifacts-mode | 986 | Ok(self |
| 987 | .link_for(GithubLinkArgs { repo_id: a.repo_id.clone() }) | |
| 988 | .await? | |
| 989 | .map_or_else(|| fail(FailureCode::NotFound, "Gone."), Outcome::Ok)) | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 990 | } |
| 991 | ||
| 992 | // --- The webhook ----------------------------------------------------------- | |
| 993 | ||
| 994 | /// Checks and records a delivery. What it asks for is done by | |
| 995 | /// `process`, after the answer has gone. | |
| 996 | pub async fn receive(&self, a: &GithubReceiveArgs) -> Result<(Received, Option<(String, Value)>)> { | |
| 997 | let answer = |status: u16, message: &str| Received { | |
| 998 | status, | |
| 999 | message: message.to_owned(), | |
| 1000 | }; | |
| 1001 | let Some(secret) = self.config.webhook_secret.as_deref() else { | |
| 1002 | return Ok((answer(404, "GitHub is not set up on this g1t."), None)); | |
| 1003 | }; | |
| 1004 | if !authentic(secret, &a.body, &a.headers) { | |
| 1005 | return Ok((answer(401, "The request was not signed with the app's webhook secret."), None)); | |
| 1006 | } | |
| 1007 | let event = a.headers.get("x-github-event").cloned().unwrap_or_default(); | |
| 1008 | let Some(delivery) = a.headers.get("x-github-delivery").filter(|id| !id.is_empty() && id.len() <= 100) else { | |
| 1009 | return Ok((answer(400, "No X-GitHub-Delivery."), None)); | |
| 1010 | }; | |
| 1011 | let now = now_ms(); | |
| 1012 | let fresh = self | |
| 1013 | .db | |
| 1014 | .prepare("INSERT OR IGNORE INTO github_deliveries (id, event, received_ms) VALUES (?, ?, ?) RETURNING id") | |
| 1015 | .bind(&[delivery.as_str().into(), event.as_str().into(), (now as f64).into()])? | |
| 1016 | .first::<Value>(None) | |
| 1017 | .await?; | |
| 1018 | if fresh.is_none() { | |
| 1019 | return Ok((answer(200, "Already received."), None)); | |
| 1020 | } | |
| 1021 | let Ok(payload) = serde_json::from_str::<Value>(&a.body) else { | |
| 1022 | return Ok((answer(400, "The body is not JSON."), None)); | |
| 1023 | }; | |
| 1024 | Ok((answer(202, "Received."), Some((event, payload)))) | |
| 1025 | } | |
| 1026 | ||
| Merge branch 'mirroring' into artifacts-mode | 1027 | pub async fn process(&self, event: &str, payload: &Value, mirrors: Option<&crate::remotes::Mirrors>) -> Result<()> { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1028 | let action = payload["action"].as_str().unwrap_or_default(); |
| 1029 | let installation = payload["installation"]["id"].as_u64(); | |
| 1030 | match (event, action) { | |
| 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. | 1031 | // An installation recorded already (GitHub sent its creation |
| 1032 | // again, or new permissions were accepted there) is kept in | |
| 1033 | // step. One nobody added from g1t names no workspace: it waits | |
| 1034 | // for an owner to add it from New project. | |
| 1035 | ("installation", "created") | ("installation", "new_permissions_accepted") => { | |
| 1036 | let Some(id) = installation else { return Ok(()) }; | |
| 1037 | if let Some(seen) = visible(&payload["installation"], &HashMap::new()) { | |
| 1038 | self.db | |
| 1039 | .prepare( | |
| 1040 | "UPDATE github_installations SET account = ?2, repository_selection = ?3, settings_url = ?4 | |
| 1041 | WHERE id = ?1", | |
| 1042 | ) | |
| 1043 | .bind(&[ | |
| 1044 | number(id), | |
| 1045 | seen.account.as_str().into(), | |
| 1046 | seen.repository_selection.as_str().into(), | |
| 1047 | seen.settings_url.as_str().into(), | |
| 1048 | ])? | |
| 1049 | .run() | |
| 1050 | .await?; | |
| 1051 | } | |
| 1052 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1053 | ("installation", "deleted") | ("installation", "suspend") | ("installation", "unsuspend") => { |
| 1054 | let Some(id) = installation else { return Ok(()) }; | |
| 1055 | match action { | |
| Merge branch 'mirroring' into artifacts-mode | 1056 | "deleted" => self.installation_gone(id, "The GitHub App was uninstalled from this account.", mirrors).await?, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1057 | "suspend" => { |
| 1058 | self.db | |
| 1059 | .prepare("UPDATE github_installations SET suspended_at = ? WHERE id = ?") | |
| 1060 | .bind(&[rfc3339(now_ms()).into(), number(id)])? | |
| 1061 | .run() | |
| 1062 | .await?; | |
| 1063 | } | |
| 1064 | _ => { | |
| 1065 | self.db | |
| 1066 | .prepare("UPDATE github_installations SET suspended_at = NULL WHERE id = ?") | |
| 1067 | .bind(&[number(id)])? | |
| 1068 | .run() | |
| 1069 | .await?; | |
| 1070 | } | |
| 1071 | } | |
| 1072 | } | |
| 1073 | ("installation_repositories", _) => { | |
| 1074 | let Some(id) = installation else { return Ok(()) }; | |
| 1075 | if let Some(selection) = payload["repository_selection"].as_str() { | |
| 1076 | self.db | |
| 1077 | .prepare("UPDATE github_installations SET repository_selection = ? WHERE id = ?") | |
| 1078 | .bind(&[selection.into(), number(id)])? | |
| 1079 | .run() | |
| 1080 | .await?; | |
| 1081 | } | |
| 1082 | for removed in payload["repositories_removed"].as_array().into_iter().flatten() { | |
| 1083 | if let Some(repo) = removed["id"].as_u64() { | |
| 1084 | self.mark_github_repo(repo, "GitHub no longer lets the app see this repository: add it back to the installation to keep it in step.") | |
| 1085 | .await?; | |
| 1086 | } | |
| 1087 | } | |
| 1088 | } | |
| 1089 | ("push", _) => { | |
| Merge branch 'mirroring' into artifacts-mode | 1090 | if let Some(mirrors) = mirrors { |
| 1091 | mirrors.on_github_push(payload).await?; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1092 | } |
| 1093 | } | |
| 1094 | ("repository", "renamed") | ("repository", "transferred") => { | |
| 1095 | let (Some(repo), Some(full_name)) = (payload["repository"]["id"].as_u64(), payload["repository"]["full_name"].as_str()) else { | |
| 1096 | return Ok(()); | |
| 1097 | }; | |
| 1098 | self.db | |
| 1099 | .prepare("UPDATE github_repos SET full_name = ? WHERE github_repo_id = ?") | |
| 1100 | .bind(&[full_name.into(), number(repo)])? | |
| 1101 | .run() | |
| 1102 | .await?; | |
| Merge branch 'mirroring' into artifacts-mode | 1103 | self.db |
| 1104 | .prepare( | |
| 1105 | "UPDATE remotes SET name = ?1, url = ?2, clone_url = ?3, recorded = 0 | |
| 1106 | WHERE provider = 'github' AND external_id = ?4", | |
| 1107 | ) | |
| 1108 | .bind(&[ | |
| 1109 | format!("github.com/{full_name}").into(), | |
| 1110 | format!("https://github.com/{full_name}").into(), | |
| 1111 | format!("https://github.com/{full_name}.git").into(), | |
| 1112 | repo.to_string().into(), | |
| 1113 | ])? | |
| 1114 | .run() | |
| 1115 | .await?; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1116 | } |
| 1117 | ("repository", "deleted") => { | |
| 1118 | if let Some(repo) = payload["repository"]["id"].as_u64() { | |
| 1119 | self.mark_github_repo(repo, "The repository was deleted on GitHub. The copy on g1t is kept.").await?; | |
| Merge branch 'mirroring' into artifacts-mode | 1120 | self.links_gone("external_id", &repo.to_string(), "was deleted on GitHub", mirrors).await?; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1121 | self.db |
| 1122 | .prepare("UPDATE github_repos SET mode = 'import' WHERE github_repo_id = ?") | |
| 1123 | .bind(&[number(repo)])? | |
| 1124 | .run() | |
| 1125 | .await?; | |
| 1126 | } | |
| 1127 | } | |
| 1128 | ("github_app_authorization", "revoked") => { | |
| 1129 | if let Some(github_id) = payload["sender"]["id"].as_u64() { | |
| 1130 | let _: u32 = g1t_kit::call(&self.identity, "github_revoked", &GithubRevokedArgs { github_id }).await?; | |
| 1131 | } | |
| 1132 | } | |
| 1133 | ("meta", "deleted") => { | |
| 1134 | let ids = self | |
| 1135 | .db | |
| 1136 | .prepare("SELECT DISTINCT id FROM github_installations") | |
| 1137 | .all() | |
| 1138 | .await? | |
| 1139 | .results::<Value>()?; | |
| 1140 | for row in ids { | |
| 1141 | if let Some(id) = row["id"].as_u64() { | |
| Merge branch 'mirroring' into artifacts-mode | 1142 | self.installation_gone(id, "g1t's GitHub App was deleted.", mirrors).await?; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1143 | } |
| 1144 | } | |
| 1145 | } | |
| 1146 | _ => {} | |
| 1147 | } | |
| 1148 | // Delivery ids are kept a week, long enough for GitHub's retries. | |
| 1149 | self.db | |
| 1150 | .prepare("DELETE FROM github_deliveries WHERE received_ms < ?") | |
| 1151 | .bind(&[((now_ms().saturating_sub(DELIVERY_DAYS * 86_400_000)) as f64).into()])? | |
| 1152 | .run() | |
| 1153 | .await?; | |
| 1154 | Ok(()) | |
| 1155 | } | |
| 1156 | ||
| Merge branch 'mirroring' into artifacts-mode | 1157 | /// The remotes GitHub no longer lets g1t reach: a mirror standing by |
| 1158 | /// becomes an ordinary repository with what it has (it can no longer | |
| 1159 | /// follow), a follower stops; a takeover keeps going and is told why. | |
| 1160 | async fn links_gone(&self, column: &str, value: &str, why: &str, mirrors: Option<&crate::remotes::Mirrors>) -> Result<()> { | |
| 1161 | let Some(mirrors) = mirrors else { return Ok(()) }; | |
| 1162 | let column = if column == "connection_id" { "connection_id" } else { "external_id" }; | |
| 1163 | let rows = self | |
| 1164 | .db | |
| 1165 | .prepare(format!("SELECT id FROM remotes WHERE provider = 'github' AND {column} = ?")) | |
| 1166 | .bind(&[value.into()])? | |
| 1167 | .all() | |
| 1168 | .await? | |
| 1169 | .results::<Value>()?; | |
| 1170 | for id in rows.iter().filter_map(|row| row["id"].as_str()) { | |
| 1171 | mirrors.link_gone(id, why).await?; | |
| 1172 | } | |
| 1173 | Ok(()) | |
| 1174 | } | |
| 1175 | ||
| 1176 | async fn installation_gone(&self, id: u64, why: &str, mirrors: Option<&crate::remotes::Mirrors>) -> Result<()> { | |
| 1177 | self.links_gone("connection_id", &id.to_string(), "lost its GitHub App installation", mirrors).await?; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1178 | self.db.prepare("DELETE FROM github_installations WHERE id = ?").bind(&[number(id)])?.run().await?; |
| 1179 | self.db.prepare("DELETE FROM github_tokens WHERE installation_id = ?").bind(&[number(id)])?.run().await?; | |
| 1180 | self.db | |
| 1181 | .prepare("UPDATE github_repos SET mode = 'import', last_error = ? WHERE installation_id = ? AND mode != 'import'") | |
| 1182 | .bind(&[why.into(), number(id)])? | |
| 1183 | .run() | |
| 1184 | .await?; | |
| 1185 | Ok(()) | |
| 1186 | } | |
| 1187 | ||
| 1188 | async fn mark_github_repo(&self, github_repo_id: u64, why: &str) -> Result<()> { | |
| 1189 | self.db | |
| 1190 | .prepare("UPDATE github_repos SET last_error = ? WHERE github_repo_id = ? AND mode != 'import'") | |
| 1191 | .bind(&[why.into(), number(github_repo_id)])? | |
| 1192 | .run() | |
| 1193 | .await?; | |
| 1194 | Ok(()) | |
| 1195 | } | |
| 1196 | } | |
| 1197 | ||
| 1198 | /// Answers the GitHub methods; `None` for any other. | |
| 1199 | pub async fn route(method: &str, body: &Value, env: &Env, ctx: &Context) -> Option<Result<Response>> { | |
| 1200 | if !method.starts_with("github_") { | |
| 1201 | return None; | |
| 1202 | } | |
| 1203 | Some(handle(method, body.clone(), env, ctx).await) | |
| 1204 | } | |
| 1205 | ||
| 1206 | async fn handle(method: &str, body: Value, env: &Env, ctx: &Context) -> Result<Response> { | |
| 1207 | let app = GithubApp::new(env)?; | |
| 1208 | match method { | |
| 1209 | "github_status" => reply(&app.status(args(body)?).await?), | |
| 1210 | "github_add_installation" => reply(&app.add_installation(args(body)?).await?), | |
| 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. | 1211 | "github_visible_installations" => reply(&app.visible_installations(args(body)?).await?), |
| Merge branch 'mirroring' into artifacts-mode | 1212 | "github_remove_installation" => { |
| 1213 | let mirrors = crate::remotes::Mirrors::new(env).ok(); | |
| 1214 | reply(&app.remove_installation(args(body)?, mirrors.as_ref()).await?) | |
| 1215 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1216 | "github_repositories" => reply(&app.repositories(args(body)?).await?), |
| 1217 | "github_import" => { | |
| 1218 | let (outcome, job) = app.import(args(body)?).await?; | |
| 1219 | if let Some(job) = job { | |
| 1220 | let env = env.clone(); | |
| 1221 | ctx.wait_until(async move { | |
| 1222 | let Ok(app) = GithubApp::new(&env) else { return }; | |
| 1223 | if let Err(error) = app.copy_issues(job).await { | |
| 1224 | worker::console_error!("github: copying issues failed: {error}"); | |
| 1225 | } | |
| 1226 | }); | |
| 1227 | } | |
| 1228 | reply(&outcome) | |
| 1229 | } | |
| 1230 | "github_link" => reply(&app.link_for(args(body)?).await?), | |
| Merge branch 'mirroring' into artifacts-mode | 1231 | "github_unlink_repo" => reply(&app.unlink_repo(args(body)?, env).await?), |
| 1232 | "github_sync" => reply(&app.sync_now(args(body)?, env).await?), | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1233 | "github_receive" => { |
| 1234 | let received: GithubReceiveArgs = args(body)?; | |
| 1235 | let (answer, work) = app.receive(&received).await?; | |
| 1236 | if let Some((event, payload)) = work { | |
| 1237 | let env = env.clone(); | |
| 1238 | ctx.wait_until(async move { | |
| 1239 | let Ok(app) = GithubApp::new(&env) else { return }; | |
| Merge branch 'mirroring' into artifacts-mode | 1240 | let mirrors = crate::remotes::Mirrors::new(&env).ok(); |
| 1241 | if let Err(error) = app.process(&event, &payload, mirrors.as_ref()).await { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1242 | worker::console_error!("github: acting on a {event} delivery failed: {error}"); |
| 1243 | } | |
| 1244 | }); | |
| 1245 | } | |
| 1246 | reply(&answer) | |
| 1247 | } | |
| 1248 | _ => Response::error("Unknown method", 404), | |
| 1249 | } | |
| 1250 | } | |
| 1251 | ||
| Merge branch 'mirroring' into artifacts-mode | 1252 | /// Whether `event` should push its repository out to its followers, given |
| 1253 | /// the repositories `pushed` out already for this batch: a sync sends every | |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 1254 | /// ref, so a push of many refs, or many pushes in one batch, is one sync. |
| Merge branch 'mirroring' into artifacts-mode | 1255 | pub fn first_push_in_batch(pushed: &mut std::collections::HashSet<String>, event: &g1t_contracts::events::Event) -> bool { |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 1256 | match event.repo_id.as_deref() { |
| 1257 | Some(repo_id) if event.kind == "git.push" => pushed.insert(repo_id.to_owned()), | |
| 1258 | _ => true, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1259 | } |
| 1260 | } | |
| 1261 | ||
| 1262 | #[cfg(test)] | |
| 1263 | mod tests { | |
| 1264 | use super::*; | |
| 1265 | ||
| 1266 | #[test] | |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 1267 | fn a_batch_pushes_each_repository_out_once() { |
| Merge branch 'mirroring' into artifacts-mode | 1268 | let event = |kind: &str, repo: &str| g1t_contracts::events::Event { |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 1269 | id: "evt_1".into(), |
| 1270 | kind: kind.into(), | |
| 1271 | source: "repos".into(), | |
| 1272 | time: "2026-10-08T00:00:00Z".into(), | |
| 1273 | repo_id: Some(repo.into()), | |
| 1274 | actor: None, | |
| 1275 | data: serde_json::json!({}), | |
| 1276 | }; | |
| 1277 | let mut pushed = std::collections::HashSet::new(); | |
| 1278 | assert!(first_push_in_batch(&mut pushed, &event("git.push", "rep_1"))); | |
| 1279 | assert!(!first_push_in_batch(&mut pushed, &event("git.push", "rep_1"))); | |
| 1280 | assert!(first_push_in_batch(&mut pushed, &event("git.push", "rep_2"))); | |
| 1281 | assert!(first_push_in_batch(&mut pushed, &event("issue.opened", "rep_1"))); | |
| 1282 | } | |
| 1283 | ||
| 1284 | #[test] | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1285 | fn times_are_read_as_github_writes_them() { |
| 1286 | assert_eq!(parse_time("1970-01-01T00:00:00Z"), Some(0)); | |
| 1287 | assert_eq!(parse_time("2016-07-11T22:14:10Z"), Some(1_468_275_250_000)); | |
| 1288 | assert_eq!(parse_time("2024-02-29T12:00:00.5Z"), Some(1_709_208_000_500)); | |
| 1289 | assert_eq!(parse_time("not a time"), None); | |
| 1290 | } | |
| 1291 | ||
| 1292 | #[test] | |
| 1293 | fn names_follow_g1ts_rules() { | |
| 1294 | assert_eq!(repo_name("Hello-World"), "hello-world"); | |
| 1295 | assert_eq!(repo_name(".github"), "github"); | |
| 1296 | assert_eq!(repo_name("site.git"), "site"); | |
| 1297 | assert!(is_valid_repo_name(&repo_name("My Repo_1.x"))); | |
| 1298 | } | |
| 1299 | ||
| 1300 | fn headers(signature: &str) -> HashMap<String, String> { | |
| 1301 | HashMap::from([("x-hub-signature-256".to_owned(), signature.to_owned())]) | |
| 1302 | } | |
| 1303 | ||
| 1304 | #[test] | |
| 1305 | fn webhooks_must_carry_the_apps_signature() { | |
| 1306 | // GitHub's documented example: secret "It's a Secret to Everybody", | |
| 1307 | // payload "Hello, World!". | |
| 1308 | let secret = "It's a Secret to Everybody"; | |
| 1309 | let signature = "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"; | |
| 1310 | assert!(authentic(secret, "Hello, World!", &headers(signature))); | |
| 1311 | assert!(!authentic(secret, "Hello, World?", &headers(signature))); | |
| 1312 | assert!(!authentic("another secret", "Hello, World!", &headers(signature))); | |
| 1313 | // The bare hex form is not what GitHub sends; it is refused. | |
| 1314 | assert!(!authentic(secret, "Hello, World!", &headers(signature.trim_start_matches("sha256=")))); | |
| 1315 | assert!(!authentic(secret, "Hello, World!", &HashMap::new())); | |
| 1316 | } | |
| 1317 | ||
| 1318 | #[test] | |
| 1319 | fn imported_issues_say_where_they_came_from() { | |
| 1320 | let issue = json!({ | |
| 1321 | "number": 12, | |
| 1322 | "html_url": "https://github.com/acme/site/issues/12", | |
| 1323 | "user": { "login": "octocat", "id": 1 }, | |
| 1324 | "created_at": "2024-01-02T03:04:05Z", | |
| 1325 | "milestone": { "title": "v1" }, | |
| 1326 | "labels": [{ "name": "Bug" }, { "name": "bug" }, { "name": "x".repeat(41) }, "Help Wanted"], | |
| 1327 | }); | |
| 1328 | let linked = imported_header(&issue, "acme/site", Some("octo")); | |
| 1329 | assert!(linked.contains("[acme/site#12](https://github.com/acme/site/issues/12)")); | |
| 1330 | assert!(linked.contains("@octo (@octocat on GitHub)")); | |
| 1331 | assert!(linked.contains("on 2024-01-02")); | |
| 1332 | assert!(linked.contains("Milestone: v1")); | |
| 1333 | assert!(imported_header(&issue, "acme/site", None).contains("[@octocat](https://github.com/octocat) on GitHub")); | |
| 1334 | assert_eq!(labels_of(&issue), vec!["bug", "help wanted"]); | |
| 1335 | } | |
| 1336 | ||
| 1337 | #[test] | |
| 1338 | fn the_jwt_issuer_prefers_the_client_id() { | |
| 1339 | let mut config = AppConfig { | |
| 1340 | app_id: "5203641".to_owned(), | |
| 1341 | slug: "g1t-sh".to_owned(), | |
| 1342 | client_id: String::new(), | |
| 1343 | private_key: Some("key".to_owned()), | |
| 1344 | webhook_secret: None, | |
| 1345 | }; | |
| 1346 | assert_eq!(config.issuer(), "5203641"); | |
| 1347 | config.client_id = "Iv23liZS94alfjIUn1eW".to_owned(); | |
| 1348 | assert_eq!(config.issuer(), "Iv23liZS94alfjIUn1eW"); | |
| 1349 | assert!(config.configured()); | |
| 1350 | assert_eq!(config.install_url(), "https://github.com/apps/g1t-sh/installations/new"); | |
| 1351 | config.private_key = None; | |
| 1352 | assert!(!config.configured()); | |
| 1353 | } | |
| 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. | 1354 | |
| 1355 | #[test] | |
| 1356 | fn installations_seen_on_github_say_where_they_are_recorded() { | |
| 1357 | let listed = vec![ | |
| 1358 | json!({ | |
| 1359 | "id": 2, | |
| 1360 | "account": { "login": "syntaqx", "type": "User" }, | |
| 1361 | "repository_selection": "selected", | |
| 1362 | "html_url": "https://github.com/settings/installations/2", | |
| 1363 | "suspended_at": null, | |
| 1364 | }), | |
| 1365 | json!({ | |
| 1366 | "id": 1, | |
| 1367 | "account": { "login": "flagon-io", "type": "Organization" }, | |
| 1368 | "repository_selection": "all", | |
| 1369 | "html_url": "https://github.com/organizations/flagon-io/settings/installations/1", | |
| 1370 | "suspended_at": "2026-10-01T00:00:00Z", | |
| 1371 | }), | |
| 1372 | // Listed twice across pages, and one with no account: shown once, and not at all. | |
| 1373 | json!({ "id": 1, "account": { "login": "flagon-io", "type": "Organization" } }), | |
| 1374 | json!({ "id": 3, "account": null }), | |
| 1375 | ]; | |
| 1376 | let recorded = HashMap::from([(2, vec!["syntaqx".to_owned()])]); | |
| 1377 | let seen = visible_installations(&listed, &recorded); | |
| 1378 | assert_eq!(seen.len(), 2); | |
| 1379 | assert_eq!(seen[0].account, "flagon-io"); | |
| 1380 | assert_eq!(seen[0].account_type, "Organization"); | |
| 1381 | assert_eq!(seen[0].repository_selection, "all"); | |
| 1382 | assert!(seen[0].suspended); | |
| 1383 | assert!(seen[0].recorded_in.is_empty()); | |
| 1384 | assert_eq!(seen[1].account, "syntaqx"); | |
| 1385 | assert_eq!(seen[1].recorded_in, vec!["syntaqx".to_owned()]); | |
| 1386 | assert!(!seen[1].suspended); | |
| 1387 | } | |
| 1388 | ||
| 1389 | #[test] | |
| 1390 | fn an_installation_without_its_settings_link_points_at_githubs_list() { | |
| 1391 | let seen = visible(&json!({ "id": 9, "account": { "login": "ada" }, "target_type": "User" }), &HashMap::new()).unwrap(); | |
| 1392 | assert_eq!(seen.settings_url, SETTINGS_URL); | |
| 1393 | assert_eq!(seen.repository_selection, "selected"); | |
| 1394 | assert_eq!(seen.account_type, "User"); | |
| 1395 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1396 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.