Skip to content

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

g1t.sh
1,148 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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

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