Skip to content
1,396 linesCodeBlameRaw
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
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
13//! same user token, so a person only ever sees what both they and the app
14//! can reach.
15//!
16//! Git goes over HTTPS with an installation access token, which this
17//! service gets by signing a JWT as the app (see `github_jwt.rs`) and keeps,
18//! sealed, until five minutes before it expires. Tokens are opaque: no
19//! length or format is assumed.
20//!
21//! A repository comes across one of three ways (`GithubMode`): imported
22//! once; mirrored, so g1t keeps a standby copy that follows GitHub; or
23//! pushed, so GitHub follows g1t. Either way g1t holds a full copy, and the
24//! project built from it is hosted on g1t like any other. Mirrored and
25//! pushed repositories are links in `remotes` (see `remotes.rs`), which
26//! does everything after the import: following pushes, takeovers,
27//! hand-backs, moving in.
28//!
29//! The webhook, `https://api.g1t.sh/hooks/github`, is checked against
30//! GITHUB_APP_WEBHOOK_SECRET in constant time, de-duplicated by
31//! `X-GitHub-Delivery`, answered with 202 at once and acted on afterwards,
32//! as every inbound hook in this service is.
33
34use std::collections::{HashMap, HashSet};
35
36use g1t_contracts::access::{self, Capability};
37use g1t_contracts::github::*;
38use g1t_contracts::integrations::Received;
39use g1t_contracts::mirrors::{MirrorActArgs, MirrorState, RepoMirror};
40use g1t_contracts::repos::{CreateArgs, Repo, RepoPath};
41use 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
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
307fn 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
329 /// GitHub's REST API, with an installation or user token.
330 pub(crate) async fn api(&self, method: Method, path: &str, token: &str, body: Option<Value>) -> Result<Answer> {
331 self.github(method, path, token, body).await
332 }
333
334 async fn github(&self, method: Method, path: &str, token: &str, body: Option<Value>) -> Result<Answer> {
335 let authorization = format!("Bearer {token}");
336 let url = if path.starts_with("https://") { path.to_owned() } else { format!("{API}{path}") };
337 http::send(
338 method,
339 &url,
340 &[
341 ("authorization", &authorization),
342 ("accept", "application/vnd.github+json"),
343 ("x-github-api-version", "2022-11-28"),
344 ],
345 body.map(|body| body.to_string()),
346 )
347 .await
348 }
349
350 /// The person's GitHub user token, from identity.
351 async fn user_token(&self, user: &User) -> Result<Outcome<String>> {
352 g1t_kit::call(&self.identity, "github_user_token", &GithubUserTokenArgs { user_id: user.id.clone() }).await
353 }
354
355 /// An installation access token, from the cache or fresh from GitHub.
356 pub(crate) async fn installation_token(&self, installation_id: u64) -> Result<std::result::Result<String, String>> {
357 #[derive(Deserialize)]
358 struct Cached {
359 token: String,
360 expires_ms: f64,
361 }
362 let bound = format!("ghi:{installation_id}");
363 let now = now_ms();
364 let cached = self
365 .db
366 .prepare("SELECT token, expires_ms FROM github_tokens WHERE installation_id = ?")
367 .bind(&[number(installation_id)])?
368 .first::<Cached>(None)
369 .await?;
370 if let (Some(cached), Some(sealer)) = (cached, &self.sealer)
371 && cached.expires_ms as u64 > now + TOKEN_MARGIN_MS
372 && let Some(token) = sealer.open(&cached.token, &bound)
373 {
374 return Ok(Ok(token));
375 }
376 let Some(key) = self.config.private_key.as_deref().filter(|_| self.config.configured()) else {
377 return Ok(Err(NOT_SET_UP.to_owned()));
378 };
379 let jwt = github_jwt::app_jwt(self.config.issuer(), key, now / 1000).await?;
380 let answer = self
381 .github(Method::Post, &format!("/app/installations/{installation_id}/access_tokens"), &jwt, None)
382 .await?;
383 if !answer.ok() {
384 return Ok(Err(answer.problem("GitHub")));
385 }
386 let body = answer.json();
387 let Some(token) = body["token"].as_str().filter(|token| !token.is_empty()).map(str::to_owned) else {
388 return Ok(Err("GitHub sent no installation token.".to_owned()));
389 };
390 // GitHub's tokens last an hour; trust its own expiry when it says.
391 let expires = body["expires_at"].as_str().and_then(parse_time).unwrap_or(now + 60 * 60 * 1000);
392 if let Some(sealer) = &self.sealer {
393 self.db
394 .prepare(
395 "INSERT INTO github_tokens (installation_id, token, expires_ms) VALUES (?1, ?2, ?3)
396 ON CONFLICT (installation_id) DO UPDATE SET token = excluded.token, expires_ms = excluded.expires_ms",
397 )
398 .bind(&[number(installation_id), sealer.seal(&token, &bound).into(), (expires as f64).into()])?
399 .run()
400 .await?;
401 }
402 Ok(Ok(token))
403 }
404
405 async fn installation(&self, workspace: &str, id: u64) -> Result<Option<InstallationRow>> {
406 self.db
407 .prepare("SELECT * FROM github_installations WHERE workspace = ? AND id = ?")
408 .bind(&[workspace.into(), number(id)])?
409 .first::<InstallationRow>(None)
410 .await
411 }
412
413 async fn link(&self, repo_id: &str) -> Result<Option<LinkRow>> {
414 self.db
415 .prepare("SELECT * FROM github_repos WHERE repo_id = ?")
416 .bind(&[repo_id.into()])?
417 .first::<LinkRow>(None)
418 .await
419 }
420
421 async fn note(&self, repo_id: &str, problem: Option<&str>) -> Result<()> {
422 let sql = if problem.is_some() {
423 "UPDATE github_repos SET last_error = ?2 WHERE repo_id = ?1"
424 } else {
425 "UPDATE github_repos SET last_error = ?2, synced_at = ?3 WHERE repo_id = ?1"
426 };
427 let statement = self.db.prepare(sql);
428 let statement = if problem.is_some() {
429 statement.bind(&[repo_id.into(), null_or(problem)])?
430 } else {
431 statement.bind(&[repo_id.into(), JsValue::NULL, rfc3339(now_ms()).into()])?
432 };
433 statement.run().await?;
434 Ok(())
435 }
436
437 pub async fn status(&self, a: GithubStatusArgs) -> Result<Outcome<GithubAppStatus>> {
438 let workspace = a.workspace.to_lowercase();
439 if !a.viewer.is_member(&workspace) {
440 return Ok(fail(FailureCode::Forbidden, "Only members of the workspace can see its GitHub installations."));
441 }
442 if !self.config.configured() {
443 return Ok(Outcome::Ok(GithubAppStatus::default()));
444 }
445 let account: GithubAccountView =
446 g1t_kit::call(&self.identity, "github_account", &json!({ "user": a.viewer })).await?;
447 let installations = self
448 .db
449 .prepare("SELECT * FROM github_installations WHERE workspace = ? ORDER BY account")
450 .bind(&[workspace.as_str().into()])?
451 .all()
452 .await?
453 .results::<InstallationRow>()?;
454 Ok(Outcome::Ok(GithubAppStatus {
455 configured: true,
456 install_url: Some(self.config.install_url()),
457 linked: account.account.is_some_and(|account| account.authorized),
458 installations: installations.into_iter().map(Into::into).collect(),
459 }))
460 }
461
462 fn owner_only<T>(actor: &User, workspace: &str) -> Option<Outcome<T>> {
463 (actor.role_in(workspace) != Some(Role::Owner) || actor.kind != PrincipalKind::User).then(|| {
464 fail(FailureCode::Forbidden, "Only an owner of the workspace can add or remove GitHub accounts.")
465 })
466 }
467
468 /// Records an installation against a workspace, once the person's own
469 /// GitHub token shows it is one they can see.
470 pub async fn add_installation(&self, a: GithubInstallationArgs) -> Result<Outcome<GithubInstallation>> {
471 let workspace = a.workspace.to_lowercase();
472 if let Some(refused) = Self::owner_only(&a.actor, &workspace) {
473 return Ok(refused);
474 }
475 if !self.config.configured() {
476 return Ok(fail(FailureCode::NotFound, NOT_SET_UP));
477 }
478 let token = match self.user_token(&a.actor).await? {
479 Outcome::Ok(token) => token,
480 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
481 };
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 {
487 return Ok(fail(
488 FailureCode::Forbidden,
489 "Your GitHub account cannot see that installation. Install the app from your own GitHub account or an organization you manage.",
490 ));
491 };
492 let now = rfc3339(now_ms());
493 let row = InstallationRow {
494 id: a.installation_id,
495 workspace: workspace.clone(),
496 account: item["account"]["login"].as_str().unwrap_or_default().to_owned(),
497 account_type: item["account"]["type"].as_str().or(item["target_type"].as_str()).unwrap_or("User").to_owned(),
498 repository_selection: item["repository_selection"].as_str().unwrap_or("selected").to_owned(),
499 settings_url: item["html_url"].as_str().unwrap_or(SETTINGS_URL).to_owned(),
500 suspended_at: item["suspended_at"].as_str().map(str::to_owned),
501 created_at: now,
502 };
503 self.db
504 .prepare(
505 "INSERT INTO github_installations
506 (id, workspace, account, account_type, repository_selection, settings_url, suspended_at, added_by, created_at)
507 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
508 ON CONFLICT (id, workspace) DO UPDATE SET account = excluded.account,
509 repository_selection = excluded.repository_selection, settings_url = excluded.settings_url,
510 suspended_at = excluded.suspended_at",
511 )
512 .bind(&[
513 number(row.id),
514 row.workspace.as_str().into(),
515 row.account.as_str().into(),
516 row.account_type.as_str().into(),
517 row.repository_selection.as_str().into(),
518 row.settings_url.as_str().into(),
519 null_or(row.suspended_at.as_deref()),
520 a.actor.id.as_str().into(),
521 row.created_at.as_str().into(),
522 ])?
523 .run()
524 .await?;
525 Ok(Outcome::Ok(row.into()))
526 }
527
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
592 /// Forgets an installation in a workspace; its mirrors stop. The app
593 /// stays installed on GitHub until it is uninstalled there.
594 pub async fn remove_installation(&self, a: GithubInstallationArgs, mirrors: Option<&crate::remotes::Mirrors>) -> Result<Outcome<bool>> {
595 let workspace = a.workspace.to_lowercase();
596 if let Some(refused) = Self::owner_only(&a.actor, &workspace) {
597 return Ok(refused);
598 }
599 self.db
600 .prepare("DELETE FROM github_installations WHERE workspace = ? AND id = ?")
601 .bind(&[workspace.as_str().into(), number(a.installation_id)])?
602 .run()
603 .await?;
604 self.db
605 .prepare("UPDATE github_repos SET mode = 'import' WHERE workspace = ? AND installation_id = ?")
606 .bind(&[workspace.as_str().into(), number(a.installation_id)])?
607 .run()
608 .await?;
609 if let Some(mirrors) = mirrors {
610 let rows = self
611 .db
612 .prepare("SELECT id FROM remotes WHERE provider = 'github' AND workspace = ? AND connection_id = ?")
613 .bind(&[workspace.as_str().into(), a.installation_id.to_string().into()])?
614 .all()
615 .await?
616 .results::<Value>()?;
617 for id in rows.iter().filter_map(|row| row["id"].as_str()) {
618 mirrors.link_gone(id, "lost its GitHub account in this workspace").await?;
619 }
620 }
621 Ok(Outcome::Ok(true))
622 }
623
624 pub async fn repositories(&self, a: GithubRepositoriesArgs) -> Result<Outcome<GithubRepositories>> {
625 let workspace = a.workspace.to_lowercase();
626 if !a.actor.is_member(&workspace) {
627 return Ok(fail(FailureCode::Forbidden, "Only members of the workspace can bring repositories into it."));
628 }
629 if self.installation(&workspace, a.installation_id).await?.is_none() {
630 return Ok(fail(FailureCode::NotFound, "That GitHub account is not connected to this workspace."));
631 }
632 let token = match self.user_token(&a.actor).await? {
633 Outcome::Ok(token) => token,
634 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
635 };
636 let page = a.page.unwrap_or(1).max(1);
637 let answer = self
638 .github(
639 Method::Get,
640 &format!("/user/installations/{}/repositories?per_page={PER_PAGE}&page={page}", a.installation_id),
641 &token,
642 None,
643 )
644 .await?;
645 if !answer.ok() {
646 return Ok(fail(FailureCode::Conflict, answer.problem("GitHub")));
647 }
648 let body = answer.json();
649 let listed = body["repositories"].as_array().cloned().unwrap_or_default();
650 let ids: Vec<u64> = listed.iter().filter_map(|repo| repo["id"].as_u64()).collect();
651 let mut linked = HashMap::new();
652 if !ids.is_empty() {
653 #[derive(Deserialize)]
654 struct Linked {
655 github_repo_id: u64,
656 repo: String,
657 }
658 let marks = vec!["?"; ids.len()].join(", ");
659 let mut bind = vec![JsValue::from(workspace.as_str())];
660 bind.extend(ids.iter().map(|id| number(*id)));
661 let rows = self
662 .db
663 .prepare(format!(
664 "SELECT github_repo_id, repo FROM github_repos WHERE workspace = ? AND github_repo_id IN ({marks})"
665 ))
666 .bind(&bind)?
667 .all()
668 .await?
669 .results::<Linked>()?;
670 linked = rows.into_iter().map(|row| (row.github_repo_id, row.repo)).collect();
671 }
672 Ok(Outcome::Ok(GithubRepositories {
673 repositories: listed
674 .iter()
675 .filter_map(|repo| {
676 let id = repo["id"].as_u64()?;
677 Some(GithubRepository {
678 id,
679 full_name: repo["full_name"].as_str()?.to_owned(),
680 name: repo["name"].as_str()?.to_owned(),
681 private: repo["private"].as_bool().unwrap_or(true),
682 description: repo["description"].as_str().map(str::to_owned),
683 default_branch: repo["default_branch"].as_str().unwrap_or("main").to_owned(),
684 linked_to: linked.get(&id).cloned(),
685 })
686 })
687 .collect(),
688 total: body["total_count"].as_u64().unwrap_or(listed.len() as u64) as u32,
689 page,
690 per_page: PER_PAGE,
691 }))
692 }
693
694 /// Brings one GitHub repository across. Returns the issues still to
695 /// copy, which the caller does after answering.
696 pub async fn import(&self, a: GithubImportArgs) -> Result<(Outcome<GithubRepoLink>, Option<IssueJob>)> {
697 let workspace = a.workspace.to_lowercase();
698 let refuse = |code, message: &str| Ok((fail(code, message), None));
699 if !a.actor.is_member(&workspace) {
700 return refuse(FailureCode::Forbidden, "Only members of the workspace can bring repositories into it.");
701 }
702 if self.installation(&workspace, a.installation_id).await?.is_none() {
703 return refuse(FailureCode::NotFound, "That GitHub account is not connected to this workspace.");
704 }
705 let user_token = match self.user_token(&a.actor).await? {
706 Outcome::Ok(token) => token,
707 Outcome::Fail(failure) => return Ok((Outcome::Fail(failure), None)),
708 };
709 // Read with the person's token: only a repository both they and the
710 // installation can reach answers.
711 let answer = self
712 .github(Method::Get, &format!("/repositories/{}", a.github_repo_id), &user_token, None)
713 .await?;
714 if !answer.ok() {
715 return refuse(FailureCode::NotFound, "That GitHub repository is not one you and the app can both reach.");
716 }
717 let github = answer.json();
718 let (Some(full_name), Some(clone_url)) = (github["full_name"].as_str(), github["clone_url"].as_str()) else {
719 return refuse(FailureCode::Conflict, "GitHub did not describe the repository.");
720 };
721 let name = a
722 .name
723 .as_deref()
724 .map(str::trim)
725 .filter(|name| !name.is_empty())
726 .map(str::to_lowercase)
727 .unwrap_or_else(|| repo_name(github["name"].as_str().unwrap_or_default()));
728 if !is_valid_repo_name(&name) {
729 return refuse(FailureCode::Invalid, "Use letters, digits, dots, hyphens and underscores only.");
730 }
731 let token = match self.installation_token(a.installation_id).await? {
732 Ok(token) => token,
733 Err(reason) => return refuse(FailureCode::Conflict, &reason),
734 };
735 let created: Outcome<Repo> = g1t_kit::call(
736 &self.repos,
737 "create",
738 &CreateArgs {
739 owner: a.actor.clone(),
740 namespace: workspace.clone(),
741 name,
742 description: github["description"].as_str().map(|text| text.chars().take(200).collect()),
743 is_private: a.private.unwrap_or_else(|| github["private"].as_bool().unwrap_or(true)),
744 import_url: Some(clone_url.to_owned()),
745 import_token: Some(token),
746 // A mirror is read-only from the start.
747 mirror: (a.mode == GithubMode::Mirror).then(|| RepoMirror {
748 state: MirrorState::Standby,
749 remote: format!("github.com/{full_name}"),
750 url: format!("https://github.com/{full_name}"),
751 since: rfc3339(now_ms()),
752 warm: false,
753 github_workflows: true,
754 hold_deploys: true,
755 }),
756 },
757 )
758 .await?;
759 let repo = match created {
760 Outcome::Ok(repo) => repo,
761 Outcome::Fail(failure) => return Ok((Outcome::Fail(failure), None)),
762 };
763 let path = format!("{}/{}", repo.namespace, repo.name);
764 let now = rfc3339(now_ms());
765 self.db
766 .prepare(
767 "INSERT INTO github_repos
768 (repo_id, workspace, repo, installation_id, github_repo_id, full_name, mode, synced_at, created_by, created_at)
769 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?8)",
770 )
771 .bind(&[
772 repo.id.as_str().into(),
773 workspace.as_str().into(),
774 path.as_str().into(),
775 number(a.installation_id),
776 number(a.github_repo_id),
777 full_name.into(),
778 a.mode.as_str().into(),
779 now.as_str().into(),
780 a.actor.id.as_str().into(),
781 ])?
782 .run()
783 .await?;
784 if a.mode != GithubMode::Import {
785 let (role, state) = if a.mode == GithubMode::Mirror { ("leader", "standby") } else { ("follower", "following") };
786 self.db
787 .prepare(
788 "INSERT INTO remotes
789 (id, repo_id, workspace, repo, provider, role, name, url, clone_url, external_id, connection_id,
790 state, state_since, state_by, settings, recorded, synced_at, created_by, created_at)
791 VALUES (?1, ?2, ?3, ?4, 'github', ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, '{}', 1, ?12, ?14, ?12)",
792 )
793 .bind(&[
794 g1t_contracts::new_id("rmt", now_ms()).into(),
795 repo.id.as_str().into(),
796 workspace.as_str().into(),
797 path.as_str().into(),
798 role.into(),
799 format!("github.com/{full_name}").into(),
800 format!("https://github.com/{full_name}").into(),
801 format!("https://github.com/{full_name}.git").into(),
802 a.github_repo_id.to_string().into(),
803 a.installation_id.to_string().into(),
804 state.into(),
805 now.as_str().into(),
806 a.actor.username.as_str().into(),
807 a.actor.id.as_str().into(),
808 ])?
809 .run()
810 .await?;
811 }
812 let job = a.issues.then(|| IssueJob {
813 actor: a.actor.clone(),
814 repo: RepoPath {
815 namespace: repo.namespace.clone(),
816 name: repo.name.clone(),
817 },
818 repo_id: repo.id.clone(),
819 full_name: full_name.to_owned(),
820 installation_id: a.installation_id,
821 });
822 let link = self.link(&repo.id).await?.map(GithubRepoLink::from);
823 Ok((link.map_or_else(|| fail(FailureCode::NotFound, "The link was not kept."), Outcome::Ok), job))
824 }
825
826 /// Copies a repository's issues, oldest first, with their labels,
827 /// milestone and state. Pull requests are left: their branches came
828 /// with the git data.
829 pub async fn copy_issues(&self, job: IssueJob) -> Result<()> {
830 let token = match self.installation_token(job.installation_id).await? {
831 Ok(token) => token,
832 Err(reason) => return self.note(&job.repo_id, Some(&format!("Issues were not copied: {reason}"))).await,
833 };
834 let mut issues: Vec<Value> = Vec::new();
835 let mut more = false;
836 for page in 1..=4 {
837 let answer = self
838 .github(
839 Method::Get,
840 &format!("/repos/{}/issues?state=all&sort=created&direction=asc&per_page=100&page={page}", job.full_name),
841 &token,
842 None,
843 )
844 .await?;
845 if !answer.ok() {
846 return self.note(&job.repo_id, Some(&format!("Issues were not copied: {}", answer.problem("GitHub")))).await;
847 }
848 let listed = answer.json().as_array().cloned().unwrap_or_default();
849 let full = listed.len() == 100;
850 issues.extend(listed.into_iter().filter(|issue| issue.get("pull_request").is_none()));
851 if issues.len() >= MAX_ISSUES {
852 more = issues.len() > MAX_ISSUES || full;
853 issues.truncate(MAX_ISSUES);
854 break;
855 }
856 if !full {
857 break;
858 }
859 }
860 let authors: HashSet<u64> = issues.iter().filter_map(|issue| issue["user"]["id"].as_u64()).collect();
861 let names: HashMap<String, String> = g1t_kit::call(
862 &self.identity,
863 "github_usernames",
864 &GithubUsernamesArgs {
865 github_ids: authors.into_iter().collect(),
866 },
867 )
868 .await
869 .unwrap_or_default();
870 let mut copied = 0u32;
871 for issue in &issues {
872 let g1t_name = issue["user"]["id"].as_u64().and_then(|id| names.get(&id.to_string())).map(String::as_str);
873 let mut body = imported_header(issue, &job.full_name, g1t_name);
874 if let Some(text) = issue["body"].as_str().filter(|text| !text.trim().is_empty()) {
875 body.push_str("\n\n");
876 body.push_str(&http::shorten(text.trim(), 60_000));
877 }
878 let opened: Outcome<Issue> = g1t_kit::call(
879 &self.work,
880 "open_issue",
881 &OpenIssueArgs {
882 actor: job.actor.clone(),
883 repo: job.repo.clone(),
884 title: issue["title"].as_str().unwrap_or("Untitled").chars().take(200).collect(),
885 body,
886 labels: labels_of(issue),
887 checks: Vec::new(),
888 milestone: None,
889 },
890 )
891 .await?;
892 let Outcome::Ok(opened) = opened else { continue };
893 if issue["state"].as_str() == Some("closed") {
894 let reason = if issue["state_reason"].as_str() == Some("not_planned") {
895 IssueReason::NotPlanned
896 } else {
897 IssueReason::Completed
898 };
899 let _: Outcome<Value> = g1t_kit::call(
900 &self.work,
901 "close_issue",
902 &IssueActionArgs {
903 actor: job.actor.clone(),
904 repo: job.repo.clone(),
905 number: opened.number,
906 reason: Some(reason),
907 },
908 )
909 .await?;
910 }
911 copied += 1;
912 }
913 self.db
914 .prepare("UPDATE github_repos SET issues_imported = ? WHERE repo_id = ?")
915 .bind(&[copied.into(), job.repo_id.as_str().into()])?
916 .run()
917 .await?;
918 if more {
919 self.note(&job.repo_id, Some(&format!("Copied the first {MAX_ISSUES} issues; the rest stay on GitHub.")))
920 .await?;
921 }
922 Ok(())
923 }
924
925 pub async fn link_for(&self, a: GithubLinkArgs) -> Result<Option<GithubRepoLink>> {
926 let Some(row) = self.link(&a.repo_id).await? else { return Ok(None) };
927 // What the repository is now is the remote's to say.
928 let role = self
929 .db
930 .prepare("SELECT role FROM remotes WHERE repo_id = ? AND provider = 'github' AND external_id = ?")
931 .bind(&[a.repo_id.as_str().into(), row.github_repo_id.to_string().into()])?
932 .first::<String>(Some("role"))
933 .await?;
934 let mut link: GithubRepoLink = row.into();
935 link.mode = match role.as_deref() {
936 Some("leader") => GithubMode::Mirror,
937 Some("follower") => GithubMode::Push,
938 _ => GithubMode::Import,
939 };
940 Ok(Some(link))
941 }
942
943 /// Stops a link to GitHub: the g1t repository keeps what it has and is
944 /// its own. Refused during a takeover (see `remotes.rs`).
945 pub async fn unlink_repo(&self, a: GithubUnlinkRepoArgs, env: &Env) -> Result<Outcome<bool>> {
946 let Some(row) = self.link(&a.repo_id).await? else {
947 return Ok(Outcome::Ok(false));
948 };
949 if let Some(refused) = refused(&a.actor, &row, Capability::ManageIntegrations) {
950 return Ok(refused);
951 }
952 let ids = self
953 .db
954 .prepare("SELECT id FROM remotes WHERE repo_id = ? AND provider = 'github'")
955 .bind(&[a.repo_id.as_str().into()])?
956 .all()
957 .await?
958 .results::<Value>()?;
959 let mirrors = crate::remotes::Mirrors::new(env)?;
960 for id in ids.iter().filter_map(|row| row["id"].as_str()) {
961 let removed = mirrors
962 .remove(g1t_contracts::mirrors::MirrorRemoveArgs { actor: a.actor.clone(), remote_id: id.to_owned() })
963 .await?;
964 if let Outcome::Fail(failure) = removed {
965 return Ok(Outcome::Fail(failure));
966 }
967 }
968 self.db
969 .prepare("UPDATE github_repos SET mode = 'import' WHERE repo_id = ?")
970 .bind(&[a.repo_id.as_str().into()])?
971 .run()
972 .await?;
973 Ok(Outcome::Ok(true))
974 }
975
976 pub async fn sync_now(&self, a: GithubUnlinkRepoArgs, env: &Env) -> Result<Outcome<GithubRepoLink>> {
977 if self.link(&a.repo_id).await?.is_none() {
978 return Ok(fail(FailureCode::NotFound, "This repository is not linked to GitHub."));
979 }
980 let synced = crate::remotes::Mirrors::new(env)?
981 .sync_now(MirrorActArgs { actor: a.actor.clone(), repo_id: a.repo_id.clone() })
982 .await?;
983 if let Outcome::Fail(failure) = synced {
984 return Ok(Outcome::Fail(failure));
985 }
986 Ok(self
987 .link_for(GithubLinkArgs { repo_id: a.repo_id.clone() })
988 .await?
989 .map_or_else(|| fail(FailureCode::NotFound, "Gone."), Outcome::Ok))
990 }
991
992 // --- The webhook -----------------------------------------------------------
993
994 /// Checks and records a delivery. What it asks for is done by
995 /// `process`, after the answer has gone.
996 pub async fn receive(&self, a: &GithubReceiveArgs) -> Result<(Received, Option<(String, Value)>)> {
997 let answer = |status: u16, message: &str| Received {
998 status,
999 message: message.to_owned(),
1000 };
1001 let Some(secret) = self.config.webhook_secret.as_deref() else {
1002 return Ok((answer(404, "GitHub is not set up on this g1t."), None));
1003 };
1004 if !authentic(secret, &a.body, &a.headers) {
1005 return Ok((answer(401, "The request was not signed with the app's webhook secret."), None));
1006 }
1007 let event = a.headers.get("x-github-event").cloned().unwrap_or_default();
1008 let Some(delivery) = a.headers.get("x-github-delivery").filter(|id| !id.is_empty() && id.len() <= 100) else {
1009 return Ok((answer(400, "No X-GitHub-Delivery."), None));
1010 };
1011 let now = now_ms();
1012 let fresh = self
1013 .db
1014 .prepare("INSERT OR IGNORE INTO github_deliveries (id, event, received_ms) VALUES (?, ?, ?) RETURNING id")
1015 .bind(&[delivery.as_str().into(), event.as_str().into(), (now as f64).into()])?
1016 .first::<Value>(None)
1017 .await?;
1018 if fresh.is_none() {
1019 return Ok((answer(200, "Already received."), None));
1020 }
1021 let Ok(payload) = serde_json::from_str::<Value>(&a.body) else {
1022 return Ok((answer(400, "The body is not JSON."), None));
1023 };
1024 Ok((answer(202, "Received."), Some((event, payload))))
1025 }
1026
1027 pub async fn process(&self, event: &str, payload: &Value, mirrors: Option<&crate::remotes::Mirrors>) -> Result<()> {
1028 let action = payload["action"].as_str().unwrap_or_default();
1029 let installation = payload["installation"]["id"].as_u64();
1030 match (event, action) {
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 }
1053 ("installation", "deleted") | ("installation", "suspend") | ("installation", "unsuspend") => {
1054 let Some(id) = installation else { return Ok(()) };
1055 match action {
1056 "deleted" => self.installation_gone(id, "The GitHub App was uninstalled from this account.", mirrors).await?,
1057 "suspend" => {
1058 self.db
1059 .prepare("UPDATE github_installations SET suspended_at = ? WHERE id = ?")
1060 .bind(&[rfc3339(now_ms()).into(), number(id)])?
1061 .run()
1062 .await?;
1063 }
1064 _ => {
1065 self.db
1066 .prepare("UPDATE github_installations SET suspended_at = NULL WHERE id = ?")
1067 .bind(&[number(id)])?
1068 .run()
1069 .await?;
1070 }
1071 }
1072 }
1073 ("installation_repositories", _) => {
1074 let Some(id) = installation else { return Ok(()) };
1075 if let Some(selection) = payload["repository_selection"].as_str() {
1076 self.db
1077 .prepare("UPDATE github_installations SET repository_selection = ? WHERE id = ?")
1078 .bind(&[selection.into(), number(id)])?
1079 .run()
1080 .await?;
1081 }
1082 for removed in payload["repositories_removed"].as_array().into_iter().flatten() {
1083 if let Some(repo) = removed["id"].as_u64() {
1084 self.mark_github_repo(repo, "GitHub no longer lets the app see this repository: add it back to the installation to keep it in step.")
1085 .await?;
1086 }
1087 }
1088 }
1089 ("push", _) => {
1090 if let Some(mirrors) = mirrors {
1091 mirrors.on_github_push(payload).await?;
1092 }
1093 }
1094 ("repository", "renamed") | ("repository", "transferred") => {
1095 let (Some(repo), Some(full_name)) = (payload["repository"]["id"].as_u64(), payload["repository"]["full_name"].as_str()) else {
1096 return Ok(());
1097 };
1098 self.db
1099 .prepare("UPDATE github_repos SET full_name = ? WHERE github_repo_id = ?")
1100 .bind(&[full_name.into(), number(repo)])?
1101 .run()
1102 .await?;
1103 self.db
1104 .prepare(
1105 "UPDATE remotes SET name = ?1, url = ?2, clone_url = ?3, recorded = 0
1106 WHERE provider = 'github' AND external_id = ?4",
1107 )
1108 .bind(&[
1109 format!("github.com/{full_name}").into(),
1110 format!("https://github.com/{full_name}").into(),
1111 format!("https://github.com/{full_name}.git").into(),
1112 repo.to_string().into(),
1113 ])?
1114 .run()
1115 .await?;
1116 }
1117 ("repository", "deleted") => {
1118 if let Some(repo) = payload["repository"]["id"].as_u64() {
1119 self.mark_github_repo(repo, "The repository was deleted on GitHub. The copy on g1t is kept.").await?;
1120 self.links_gone("external_id", &repo.to_string(), "was deleted on GitHub", mirrors).await?;
1121 self.db
1122 .prepare("UPDATE github_repos SET mode = 'import' WHERE github_repo_id = ?")
1123 .bind(&[number(repo)])?
1124 .run()
1125 .await?;
1126 }
1127 }
1128 ("github_app_authorization", "revoked") => {
1129 if let Some(github_id) = payload["sender"]["id"].as_u64() {
1130 let _: u32 = g1t_kit::call(&self.identity, "github_revoked", &GithubRevokedArgs { github_id }).await?;
1131 }
1132 }
1133 ("meta", "deleted") => {
1134 let ids = self
1135 .db
1136 .prepare("SELECT DISTINCT id FROM github_installations")
1137 .all()
1138 .await?
1139 .results::<Value>()?;
1140 for row in ids {
1141 if let Some(id) = row["id"].as_u64() {
1142 self.installation_gone(id, "g1t's GitHub App was deleted.", mirrors).await?;
1143 }
1144 }
1145 }
1146 _ => {}
1147 }
1148 // Delivery ids are kept a week, long enough for GitHub's retries.
1149 self.db
1150 .prepare("DELETE FROM github_deliveries WHERE received_ms < ?")
1151 .bind(&[((now_ms().saturating_sub(DELIVERY_DAYS * 86_400_000)) as f64).into()])?
1152 .run()
1153 .await?;
1154 Ok(())
1155 }
1156
1157 /// The remotes GitHub no longer lets g1t reach: a mirror standing by
1158 /// becomes an ordinary repository with what it has (it can no longer
1159 /// follow), a follower stops; a takeover keeps going and is told why.
1160 async fn links_gone(&self, column: &str, value: &str, why: &str, mirrors: Option<&crate::remotes::Mirrors>) -> Result<()> {
1161 let Some(mirrors) = mirrors else { return Ok(()) };
1162 let column = if column == "connection_id" { "connection_id" } else { "external_id" };
1163 let rows = self
1164 .db
1165 .prepare(format!("SELECT id FROM remotes WHERE provider = 'github' AND {column} = ?"))
1166 .bind(&[value.into()])?
1167 .all()
1168 .await?
1169 .results::<Value>()?;
1170 for id in rows.iter().filter_map(|row| row["id"].as_str()) {
1171 mirrors.link_gone(id, why).await?;
1172 }
1173 Ok(())
1174 }
1175
1176 async fn installation_gone(&self, id: u64, why: &str, mirrors: Option<&crate::remotes::Mirrors>) -> Result<()> {
1177 self.links_gone("connection_id", &id.to_string(), "lost its GitHub App installation", mirrors).await?;
1178 self.db.prepare("DELETE FROM github_installations WHERE id = ?").bind(&[number(id)])?.run().await?;
1179 self.db.prepare("DELETE FROM github_tokens WHERE installation_id = ?").bind(&[number(id)])?.run().await?;
1180 self.db
1181 .prepare("UPDATE github_repos SET mode = 'import', last_error = ? WHERE installation_id = ? AND mode != 'import'")
1182 .bind(&[why.into(), number(id)])?
1183 .run()
1184 .await?;
1185 Ok(())
1186 }
1187
1188 async fn mark_github_repo(&self, github_repo_id: u64, why: &str) -> Result<()> {
1189 self.db
1190 .prepare("UPDATE github_repos SET last_error = ? WHERE github_repo_id = ? AND mode != 'import'")
1191 .bind(&[why.into(), number(github_repo_id)])?
1192 .run()
1193 .await?;
1194 Ok(())
1195 }
1196}
1197
1198/// Answers the GitHub methods; `None` for any other.
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?),
1211 "github_visible_installations" => reply(&app.visible_installations(args(body)?).await?),
1212 "github_remove_installation" => {
1213 let mirrors = crate::remotes::Mirrors::new(env).ok();
1214 reply(&app.remove_installation(args(body)?, mirrors.as_ref()).await?)
1215 }
1216 "github_repositories" => reply(&app.repositories(args(body)?).await?),
1217 "github_import" => {
1218 let (outcome, job) = app.import(args(body)?).await?;
1219 if let Some(job) = job {
1220 let env = env.clone();
1221 ctx.wait_until(async move {
1222 let Ok(app) = GithubApp::new(&env) else { return };
1223 if let Err(error) = app.copy_issues(job).await {
1224 worker::console_error!("github: copying issues failed: {error}");
1225 }
1226 });
1227 }
1228 reply(&outcome)
1229 }
1230 "github_link" => reply(&app.link_for(args(body)?).await?),
1231 "github_unlink_repo" => reply(&app.unlink_repo(args(body)?, env).await?),
1232 "github_sync" => reply(&app.sync_now(args(body)?, env).await?),
1233 "github_receive" => {
1234 let received: GithubReceiveArgs = args(body)?;
1235 let (answer, work) = app.receive(&received).await?;
1236 if let Some((event, payload)) = work {
1237 let env = env.clone();
1238 ctx.wait_until(async move {
1239 let Ok(app) = GithubApp::new(&env) else { return };
1240 let mirrors = crate::remotes::Mirrors::new(&env).ok();
1241 if let Err(error) = app.process(&event, &payload, mirrors.as_ref()).await {
1242 worker::console_error!("github: acting on a {event} delivery failed: {error}");
1243 }
1244 });
1245 }
1246 reply(&answer)
1247 }
1248 _ => Response::error("Unknown method", 404),
1249 }
1250}
1251
1252/// Whether `event` should push its repository out to its followers, given
1253/// the repositories `pushed` out already for this batch: a sync sends every
1254/// ref, so a push of many refs, or many pushes in one batch, is one sync.
1255pub fn first_push_in_batch(pushed: &mut std::collections::HashSet<String>, event: &g1t_contracts::events::Event) -> bool {
1256 match event.repo_id.as_deref() {
1257 Some(repo_id) if event.kind == "git.push" => pushed.insert(repo_id.to_owned()),
1258 _ => true,
1259 }
1260}
1261
1262#[cfg(test)]
1263mod tests {
1264 use super::*;
1265
1266 #[test]
1267 fn a_batch_pushes_each_repository_out_once() {
1268 let event = |kind: &str, repo: &str| g1t_contracts::events::Event {
1269 id: "evt_1".into(),
1270 kind: kind.into(),
1271 source: "repos".into(),
1272 time: "2026-10-08T00:00:00Z".into(),
1273 repo_id: Some(repo.into()),
1274 actor: None,
1275 data: serde_json::json!({}),
1276 };
1277 let mut pushed = std::collections::HashSet::new();
1278 assert!(first_push_in_batch(&mut pushed, &event("git.push", "rep_1")));
1279 assert!(!first_push_in_batch(&mut pushed, &event("git.push", "rep_1")));
1280 assert!(first_push_in_batch(&mut pushed, &event("git.push", "rep_2")));
1281 assert!(first_push_in_batch(&mut pushed, &event("issue.opened", "rep_1")));
1282 }
1283
1284 #[test]
1285 fn times_are_read_as_github_writes_them() {
1286 assert_eq!(parse_time("1970-01-01T00:00:00Z"), Some(0));
1287 assert_eq!(parse_time("2016-07-11T22:14:10Z"), Some(1_468_275_250_000));
1288 assert_eq!(parse_time("2024-02-29T12:00:00.5Z"), Some(1_709_208_000_500));
1289 assert_eq!(parse_time("not a time"), None);
1290 }
1291
1292 #[test]
1293 fn names_follow_g1ts_rules() {
1294 assert_eq!(repo_name("Hello-World"), "hello-world");
1295 assert_eq!(repo_name(".github"), "github");
1296 assert_eq!(repo_name("site.git"), "site");
1297 assert!(is_valid_repo_name(&repo_name("My Repo_1.x")));
1298 }
1299
1300 fn headers(signature: &str) -> HashMap<String, String> {
1301 HashMap::from([("x-hub-signature-256".to_owned(), signature.to_owned())])
1302 }
1303
1304 #[test]
1305 fn webhooks_must_carry_the_apps_signature() {
1306 // GitHub's documented example: secret "It's a Secret to Everybody",
1307 // payload "Hello, World!".
1308 let secret = "It's a Secret to Everybody";
1309 let signature = "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
1310 assert!(authentic(secret, "Hello, World!", &headers(signature)));
1311 assert!(!authentic(secret, "Hello, World?", &headers(signature)));
1312 assert!(!authentic("another secret", "Hello, World!", &headers(signature)));
1313 // The bare hex form is not what GitHub sends; it is refused.
1314 assert!(!authentic(secret, "Hello, World!", &headers(signature.trim_start_matches("sha256="))));
1315 assert!(!authentic(secret, "Hello, World!", &HashMap::new()));
1316 }
1317
1318 #[test]
1319 fn imported_issues_say_where_they_came_from() {
1320 let issue = json!({
1321 "number": 12,
1322 "html_url": "https://github.com/acme/site/issues/12",
1323 "user": { "login": "octocat", "id": 1 },
1324 "created_at": "2024-01-02T03:04:05Z",
1325 "milestone": { "title": "v1" },
1326 "labels": [{ "name": "Bug" }, { "name": "bug" }, { "name": "x".repeat(41) }, "Help Wanted"],
1327 });
1328 let linked = imported_header(&issue, "acme/site", Some("octo"));
1329 assert!(linked.contains("[acme/site#12](https://github.com/acme/site/issues/12)"));
1330 assert!(linked.contains("@octo (@octocat on GitHub)"));
1331 assert!(linked.contains("on 2024-01-02"));
1332 assert!(linked.contains("Milestone: v1"));
1333 assert!(imported_header(&issue, "acme/site", None).contains("[@octocat](https://github.com/octocat) on GitHub"));
1334 assert_eq!(labels_of(&issue), vec!["bug", "help wanted"]);
1335 }
1336
1337 #[test]
1338 fn the_jwt_issuer_prefers_the_client_id() {
1339 let mut config = AppConfig {
1340 app_id: "5203641".to_owned(),
1341 slug: "g1t-sh".to_owned(),
1342 client_id: String::new(),
1343 private_key: Some("key".to_owned()),
1344 webhook_secret: None,
1345 };
1346 assert_eq!(config.issuer(), "5203641");
1347 config.client_id = "Iv23liZS94alfjIUn1eW".to_owned();
1348 assert_eq!(config.issuer(), "Iv23liZS94alfjIUn1eW");
1349 assert!(config.configured());
1350 assert_eq!(config.install_url(), "https://github.com/apps/g1t-sh/installations/new");
1351 config.private_key = None;
1352 assert!(!config.configured());
1353 }
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 }
1396}