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