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