Skip to content
1,396 linesCodeBlameRaw

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 look1//! 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 look13//! 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-mode22//! 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 look28//!
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
34use std::collections::{HashMap, HashSet};
35
36use g1t_contracts::access::{self, Capability};
37use g1t_contracts::github::*;
38use g1t_contracts::integrations::Received;
Merge branch 'mirroring' into artifacts-mode39use g1t_contracts::mirrors::{MirrorActArgs, MirrorState, RepoMirror};
40use g1t_contracts::repos::{CreateArgs, Repo, RepoPath};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look41use g1t_contracts::time::rfc3339;
42use g1t_contracts::work::{Issue, IssueActionArgs, IssueReason, OpenIssueArgs};
43use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, is_valid_repo_name};
44use g1t_kit::{args, now_ms, reply};
45use g1t_secrets::{self as crypto, Sealer};
46use serde::Deserialize;
47use serde_json::{Value, json};
48use worker::wasm_bindgen::JsValue;
49use worker::{Context, D1Database, Env, Fetcher, Method, Response, Result};
50
51use crate::github_jwt;
52use crate::http::{self, Answer};
53
54const API: &str = "https://api.github.com";
55/// An installation token is used until this close to its expiry.
56const TOKEN_MARGIN_MS: u64 = 5 * 60 * 1000;
57/// The most issues copied in one import, and per page asked of GitHub.
58const MAX_ISSUES: usize = 200;
59const PER_PAGE: u32 = 50;
60/// How long a delivery id is remembered, to drop GitHub's retries.
61const DELIVERY_DAYS: u64 = 7;
62
63/// The app, as this g1t is configured. Only `configured()` ones are used.
64pub 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
72impl 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`.
109pub 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.
130pub 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.
142pub 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.
149pub 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.
163pub 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)]
185struct 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
196impl 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)]
212struct 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
224impl 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.
241pub struct IssueJob {
242 actor: User,
243 repo: RepoPath,
244 repo_id: String,
245 full_name: String,
246 installation_id: u64,
247}
248
249pub struct GithubApp {
250 db: D1Database,
251 sealer: Option<Sealer>,
252 identity: Fetcher,
253 work: Fetcher,
254 repos: Fetcher,
255 config: AppConfig,
256}
257
258fn 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.
266fn 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.276const 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.
281pub 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.
296pub 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 look307fn null_or(value: Option<&str>) -> JsValue {
308 value.map_or(JsValue::NULL, Into::into)
309}
310
311fn number(value: u64) -> JsValue {
312 (value as f64).into()
313}
314
315const NOT_SET_UP: &str = "GitHub is not set up on this g1t.";
316
317impl 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-mode329 /// 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 look334 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-mode356 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 look357 #[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 look487 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 look500 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 look592 /// 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-mode594 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 look595 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-mode609 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 look621 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-mode746 // 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 look756 },
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-mode784 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 look812 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 bar888 milestone: None,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look889 },
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-mode926 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 look941 }
942
Merge branch 'mirroring' into artifacts-mode943 /// 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 look946 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-mode952 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 look968 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-mode976 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 look978 return Ok(fail(FailureCode::NotFound, "This repository is not linked to GitHub."));
979 }
Merge branch 'mirroring' into artifacts-mode980 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 look985 }
Merge branch 'mirroring' into artifacts-mode986 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 look990 }
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-mode1027 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 look1028 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 look1053 ("installation", "deleted") | ("installation", "suspend") | ("installation", "unsuspend") => {
1054 let Some(id) = installation else { return Ok(()) };
1055 match action {
Merge branch 'mirroring' into artifacts-mode1056 "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 look1057 "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-mode1090 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 look1092 }
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-mode1103 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 look1116 }
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-mode1120 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 look1121 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-mode1142 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 look1143 }
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-mode1157 /// 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 look1178 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.
1199pub 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
1206async 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-mode1212 "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 look1216 "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-mode1231 "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 look1233 "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-mode1240 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 look1242 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-mode1252/// 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-guardrails1254/// ref, so a push of many refs, or many pushes in one batch, is one sync.
Merge branch 'mirroring' into artifacts-mode1255pub fn first_push_in_batch(pushed: &mut std::collections::HashSet<String>, event: &g1t_contracts::events::Event) -> bool {
Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails1256 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 look1259 }
1260}
1261
1262#[cfg(test)]
1263mod tests {
1264 use super::*;
1265
1266 #[test]
Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails1267 fn a_batch_pushes_each_repository_out_once() {
Merge branch 'mirroring' into artifacts-mode1268 let event = |kind: &str, repo: &str| g1t_contracts::events::Event {
Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails1269 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 look1285 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 look1396}

This file's history is long; its oldest lines are credited to the oldest commit read.