Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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