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