1use std::collections::{BTreeMap, BTreeSet};
4use std::sync::{Arc, Mutex, RwLock};
5
6use base64::Engine;
7use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
8use http::HeaderMap;
9use reqwest::Method;
10use serde::Deserialize;
11use serde_json::{Value, json};
12use vgi_forge::{
13 AccessSource, ApplyReport, BindCallback, BindRequest, BindStep, BootstrapStep, Capabilities,
14 Collaborator, Drift, Forge, ForgeAccount, ForgeError, ForgeEvent, ForgeHooks, ForgeKind,
15 ForgeRole, HookDecision, IndirectAccess, LinkCallback, LinkMethod, LinkStep, Namespace,
16 NamespaceBinding, NamespaceKind, Projection, ProtectionSpec, ProtectionState, RepoSpec,
17 RepoState, RequiredCheckKind, Resource, Result, RoleAssignment, RoleChange, RoleOutcome,
18 StepAction, StepOutcome, Unlisted, VgiConfig, Visibility, async_trait, collapse_to_ladder,
19 default_diff, validate_repo_path,
20};
21
22use crate::api::{Api, Auth};
23use crate::config::{GitHubConfig, JwtIssuer};
24use crate::jwt::{AppKeySigner, app_jwt};
25use crate::manifest::missing_permissions;
26use crate::plan::{
27 CENTRAL_REPO, CODEOWNERS_PATH, CheckGuard, GUARDED_PATH, ORG_RULESET_NAME, RULESET_NAME,
28 WORKFLOW_PATH, github_plan, render_codeowners,
29};
30use crate::secret::Secret;
31use crate::webhook;
32
33mod guard;
34
35const ORG_LADDER: [ForgeRole; 5] = [
37 ForgeRole::Read,
38 ForgeRole::Triage,
39 ForgeRole::Write,
40 ForgeRole::Maintain,
41 ForgeRole::Admin,
42];
43
44const USER_LADDER: [ForgeRole; 1] = [ForgeRole::Write];
47
48const PERMS_ADMIN: &[(&str, &str)] = &[("administration", "write"), ("metadata", "read")];
56const PERMS_CONTENTS: &[(&str, &str)] = &[("contents", "write"), ("metadata", "read")];
57const PERMS_VARIABLES: &[(&str, &str)] = &[("actions_variables", "write"), ("metadata", "read")];
58const PERMS_READ_CONTENTS: &[(&str, &str)] = &[("contents", "read"), ("metadata", "read")];
59const PERMS_METADATA: &[(&str, &str)] = &[("metadata", "read")];
60const PERMS_ACCESS: &[(&str, &str)] = &[("administration", "read"), ("metadata", "read")];
63const PERMS_ACCESS_ORG: &[(&str, &str)] = &[
64 ("administration", "read"),
65 ("members", "read"),
66 ("metadata", "read"),
67];
68const PERMS_ORG_RULESETS: &[(&str, &str)] = &[("organization_administration", "write")];
73
74#[derive(Debug, Clone, PartialEq, Eq)]
84#[non_exhaustive]
85pub struct RequiredWorkflowPin {
86 pub repository_id: u64,
88 pub sha: String,
90 pub check: String,
92}
93
94impl RequiredWorkflowPin {
95 pub fn new(repository_id: u64, sha: impl Into<String>, check: impl Into<String>) -> Self {
97 RequiredWorkflowPin {
98 repository_id,
99 sha: sha.into(),
100 check: check.into(),
101 }
102 }
103}
104
105const DEVICE_CODE_MAX_LIFETIME_SECS: u64 = 900;
108
109const MIN_STATE_LEN: usize = 22;
111
112pub struct GitHubForge {
120 config: GitHubConfig,
121 api: Api,
122 signer: Arc<dyn AppKeySigner>,
123 webhook_secret: Secret,
124 namespaces: RwLock<BTreeMap<Resource, Namespace>>,
125 actions_app_id: Mutex<Option<u64>>,
126 client_secret: Option<Secret>,
127 required_workflow: RwLock<BTreeMap<Resource, bool>>,
131 check_ready: RwLock<BTreeMap<Resource, bool>>,
135 pins: RwLock<BTreeMap<Resource, RequiredWorkflowPin>>,
136 managed: RwLock<BTreeMap<Resource, BTreeSet<u64>>>,
139 org_locks: Mutex<BTreeMap<Resource, Arc<tokio::sync::Mutex<()>>>>,
141 verify_trust_action: Mutex<Option<String>>,
144}
145
146impl std::fmt::Debug for GitHubForge {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 f.debug_struct("GitHubForge")
149 .field("host", &self.config.host)
150 .field("app_id", &self.config.app_id)
151 .field("signer", &"<redacted>")
152 .field("webhook_secret", &self.webhook_secret)
153 .finish_non_exhaustive()
154 }
155}
156
157impl GitHubForge {
158 pub fn new(
161 config: GitHubConfig,
162 signer: Arc<dyn AppKeySigner>,
163 webhook_secret: Secret,
164 ) -> Result<Self> {
165 if webhook_secret.expose().is_empty() {
166 return Err(ForgeError::Config("empty webhook secret".into()));
167 }
168 let slug_ok = !config.app_slug.is_empty()
169 && config
170 .app_slug
171 .bytes()
172 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
173 if !slug_ok {
174 return Err(ForgeError::Config(format!(
175 "App slug `{}` must be lowercase letters, digits and `-`",
176 config.app_slug
177 )));
178 }
179 let api = Api::new(
180 config.api_base.clone(),
181 config.web_base.clone(),
182 config.request_timeout,
183 )?;
184 let actions_app_id = Mutex::new(config.actions_integration_id);
185 Ok(GitHubForge {
186 config,
187 api,
188 signer,
189 webhook_secret,
190 namespaces: RwLock::new(BTreeMap::new()),
191 actions_app_id,
192 client_secret: None,
193 required_workflow: RwLock::new(BTreeMap::new()),
194 check_ready: RwLock::new(BTreeMap::new()),
195 pins: RwLock::new(BTreeMap::new()),
196 managed: RwLock::new(BTreeMap::new()),
197 org_locks: Mutex::new(BTreeMap::new()),
198 verify_trust_action: Mutex::new(None),
199 })
200 }
201
202 pub fn with_client_secret(mut self, secret: Secret) -> Self {
208 self.client_secret = Some(secret);
209 self
210 }
211
212 pub fn config(&self) -> &GitHubConfig {
214 &self.config
215 }
216
217 pub(crate) fn api(&self) -> &Api {
218 &self.api
219 }
220
221 pub(crate) fn webhook_secret(&self) -> &Secret {
222 &self.webhook_secret
223 }
224
225 pub(crate) async fn repo_token_for(
228 &self,
229 repo: &Resource,
230 perms: &[(&str, &str)],
231 ) -> Result<(Secret, String, String)> {
232 self.repo_token(repo, perms).await
233 }
234
235 pub fn register_namespace(&self, ns: Namespace) -> Result<()> {
241 if ns.resource.host() != self.config.host || !ns.resource.is_namespace() {
242 return Err(ForgeError::WrongResource {
243 resource: ns.resource.to_string(),
244 expected: format!("a namespace on `{}`", self.config.host),
245 });
246 }
247 self.namespaces
248 .write()
249 .expect("namespace lock poisoned")
250 .insert(ns.resource.clone(), ns);
251 Ok(())
252 }
253
254 pub fn unregister_namespace(&self, ns: &Resource) {
256 self.namespaces
257 .write()
258 .expect("namespace lock poisoned")
259 .remove(ns);
260 self.required_workflow
261 .write()
262 .expect("lock poisoned")
263 .remove(ns);
264 self.pins.write().expect("lock poisoned").remove(ns);
265 self.managed.write().expect("lock poisoned").remove(ns);
266 self.check_ready.write().expect("lock poisoned").remove(ns);
267 }
268
269 pub fn set_managed_repositories(&self, ns: &Resource, ids: impl IntoIterator<Item = u64>) {
277 self.managed
278 .write()
279 .expect("lock poisoned")
280 .insert(ns.clone(), ids.into_iter().collect());
281 }
282
283 pub fn managed_repositories(&self, ns: &Resource) -> Option<BTreeSet<u64>> {
286 self.managed.read().expect("lock poisoned").get(ns).cloned()
287 }
288
289 fn org_lock(&self, ns: &Resource) -> Arc<tokio::sync::Mutex<()>> {
290 self.org_locks
291 .lock()
292 .expect("lock poisoned")
293 .entry(ns.clone())
294 .or_default()
295 .clone()
296 }
297
298 pub fn set_bridge_checks_ready(&self, ns: &Resource, ready: bool) {
303 self.check_ready
304 .write()
305 .expect("lock poisoned")
306 .insert(ns.clone(), ready);
307 }
308
309 pub fn bridge_checks_ready(&self, ns: &Resource) -> Option<bool> {
312 self.check_ready
313 .read()
314 .expect("lock poisoned")
315 .get(ns)
316 .copied()
317 }
318
319 pub async fn detect_bridge_checks(&self, ns: &Resource) -> Result<bool> {
327 Ok(self.detect_installation(ns).await?.0)
328 }
329
330 pub async fn detect_installation(&self, ns: &Resource) -> Result<(bool, Vec<String>)> {
336 let namespace = self.namespace(ns)?;
337 let Some(installation) = namespace.installation_id else {
338 self.set_bridge_checks_ready(ns, false);
339 return Ok((false, Vec::new()));
340 };
341 let jwt = self.jwt().await?;
342 let inst: InstallationJson = self
343 .api
344 .json(
345 Method::GET,
346 self.api
347 .url(&["app", "installations", &installation.to_string()]),
348 Auth::Bearer(&jwt),
349 None,
350 "installation",
351 )
352 .await?;
353 let ready = crate::manifest::check_ready(&inst.permissions, &inst.events);
354 self.set_bridge_checks_ready(ns, ready);
355 Ok((ready, installation_missing(&inst)))
356 }
357
358 pub fn set_required_workflow(&self, ns: &Resource, available: bool) {
363 self.required_workflow
364 .write()
365 .expect("lock poisoned")
366 .insert(ns.clone(), available);
367 }
368
369 pub fn set_required_workflow_pin(&self, ns: &Resource, pin: RequiredWorkflowPin) {
371 self.pins
372 .write()
373 .expect("lock poisoned")
374 .insert(ns.clone(), pin);
375 }
376
377 pub fn required_workflow_pin(&self, ns: &Resource) -> Option<RequiredWorkflowPin> {
379 self.pins.read().expect("lock poisoned").get(ns).cloned()
380 }
381
382 pub async fn detect_required_workflow(&self, ns: &Resource) -> Result<bool> {
398 let namespace = self.namespace(ns)?;
399 let available = self.probe_org_rulesets(&namespace).await?;
400 self.set_required_workflow(ns, available);
401 Ok(available)
402 }
403
404 async fn probe_org_rulesets(&self, ns: &Namespace) -> Result<bool> {
405 if ns.kind != NamespaceKind::Organization || ns.installation_id.is_none() {
406 return Ok(false);
407 }
408 let token = match self.installation_token(ns, None, PERMS_ORG_RULESETS).await {
409 Ok(t) => t,
410 Err(ForgeError::Rejected { status: 422, .. } | ForgeError::Forbidden(_)) => {
411 return Ok(false);
412 }
413 Err(e) => return Err(e),
414 };
415 let url = self.api.url(&["orgs", ns.resource.owner(), "rulesets"]);
416 match self
417 .api
418 .get_all::<Value>(url, Auth::Bearer(&token), "org rulesets")
419 .await
420 {
421 Ok(_) => Ok(true),
422 Err(ForgeError::Forbidden(_) | ForgeError::NotFound { .. }) => Ok(false),
423 Err(e) => Err(e),
424 }
425 }
426
427 fn required_workflow_known(&self, ns: &Resource) -> bool {
428 self.required_workflow
429 .read()
430 .expect("lock poisoned")
431 .get(ns)
432 .copied()
433 .unwrap_or(false)
434 }
435
436 pub fn new_state() -> Result<String> {
440 let mut bytes = [0u8; 32];
441 aws_lc_rs::rand::fill(&mut bytes)
442 .map_err(|_| ForgeError::Config("system RNG unavailable".into()))?;
443 Ok(URL_SAFE_NO_PAD.encode(bytes))
444 }
445
446 pub async fn fetch_web_flow_key(&self) -> Result<Vec<u8>> {
451 let url = self.api.web_url(&["web-flow.gpg"]);
452 let resp = self
453 .api
454 .send(Method::GET, url, Auth::None, None, "web-flow key")
455 .await?;
456 resp.bytes()
457 .await
458 .map(|b| b.to_vec())
459 .map_err(|e| ForgeError::Unavailable(e.without_url().to_string()))
460 }
461
462 async fn jwt(&self) -> Result<Secret> {
465 let issuer = match self.config.jwt_issuer {
466 JwtIssuer::AppId => self.config.app_id.to_string(),
467 _ => self.config.client_id.clone(),
468 };
469 app_jwt(self.signer.as_ref(), &issuer).await
470 }
471
472 async fn installation_token(
475 &self,
476 ns: &Namespace,
477 repo: Option<&str>,
478 perms: &[(&str, &str)],
479 ) -> Result<Secret> {
480 let installation = ns.installation_id.ok_or_else(|| ForgeError::Unsupported {
481 operation: "forge automation".into(),
482 hint: format!(
483 "namespace `{}` is in manual mode (no App installation); run the steps by hand \
484 with `vgi repo init`",
485 ns.resource
486 ),
487 })?;
488 let jwt = self.jwt().await?;
489 let permissions: BTreeMap<_, _> = perms.iter().copied().collect();
490 let mut body = json!({ "permissions": permissions });
491 if let Some(repo) = repo {
492 body["repositories"] = json!([repo]);
493 }
494 let url = self.api.url(&[
495 "app",
496 "installations",
497 &installation.to_string(),
498 "access_tokens",
499 ]);
500 #[derive(Deserialize)]
501 struct Token {
502 token: String,
503 }
504 let t: Token = self
505 .api
506 .json(
507 Method::POST,
508 url,
509 Auth::Bearer(&jwt),
510 Some(&body),
511 "installation token",
512 )
513 .await
514 .map_err(|e| match e {
515 ForgeError::Rejected { status: 422, .. } if repo.is_some() => {
518 ForgeError::NotFound {
519 what: format!(
520 "{}/{} (not visible to the App installation)",
521 ns.resource,
522 repo.unwrap_or_default()
523 ),
524 }
525 }
526 e => e,
527 })?;
528 Ok(Secret::new(t.token))
529 }
530
531 fn namespace(&self, ns: &Resource) -> Result<Namespace> {
532 self.namespaces
533 .read()
534 .expect("namespace lock poisoned")
535 .get(ns)
536 .cloned()
537 .ok_or_else(|| ForgeError::NotBound {
538 namespace: ns.to_string(),
539 })
540 }
541
542 fn locate<'r>(&self, repo: &'r Resource) -> Result<(Namespace, &'r str, &'r str)> {
545 if repo.host() != self.config.host {
546 return Err(ForgeError::WrongResource {
547 resource: repo.to_string(),
548 expected: format!("a repository on `{}`", self.config.host),
549 });
550 }
551 repo.require_owner_repo()?;
555 let name = repo.repo_name().ok_or_else(|| ForgeError::WrongResource {
556 resource: repo.to_string(),
557 expected: "a repository (`<host>/<owner>/<repo>`), not a namespace".into(),
558 })?;
559 Ok((self.namespace(&repo.namespace())?, repo.owner(), name))
560 }
561
562 async fn repo_token(
563 &self,
564 repo: &Resource,
565 perms: &[(&str, &str)],
566 ) -> Result<(Secret, String, String)> {
567 let (ns, owner, name) = self.locate(repo)?;
568 let token = self.installation_token(&ns, Some(name), perms).await?;
569 Ok((token, owner.to_string(), name.to_string()))
570 }
571
572 async fn actions_app_id(&self, token: &Secret) -> Result<u64> {
574 if let Some(id) = *self.actions_app_id.lock().expect("lock poisoned") {
575 return Ok(id);
576 }
577 #[derive(Deserialize)]
578 struct App {
579 id: u64,
580 }
581 let app: App = self
582 .api
583 .json(
584 Method::GET,
585 self.api.url(&["apps", "github-actions"]),
586 Auth::Bearer(token),
587 None,
588 "GitHub Actions app",
589 )
590 .await?;
591 *self.actions_app_id.lock().expect("lock poisoned") = Some(app.id);
592 Ok(app.id)
593 }
594
595 async fn revoke_user_token(&self, token: &Secret) {
600 let Some(secret) = &self.client_secret else {
601 return;
602 };
603 let url = self
604 .api
605 .url(&["applications", &self.config.client_id, "token"]);
606 let body = json!({ "access_token": token.expose() });
607 if let Err(e) = self
608 .api
609 .basic_delete(url, &self.config.client_id, secret, &body)
610 .await
611 {
612 tracing::warn!(error = %e, "could not revoke a member's user token after linking");
613 }
614 }
615
616 pub async fn repository_by_id(&self, ns: &Resource, id: u64) -> Result<Option<Resource>> {
623 let namespace = self.namespace(ns)?;
624 let token = self
625 .installation_token(&namespace, None, PERMS_METADATA)
626 .await?;
627 let url = self.api.url(&["repositories", &id.to_string()]);
628 let r: Option<RepoJson> = self
629 .api
630 .get_opt(url, Auth::Bearer(&token), "repository")
631 .await?;
632 match r {
633 Some(r) if r.id == id => Ok(Some(Resource::parse_owner_repo(&format!(
634 "{}/{}",
635 self.config.host, r.full_name
636 ))?)),
637 _ => Ok(None),
638 }
639 }
640
641 fn repo_state(&self, r: &RepoJson) -> Result<RepoState> {
642 let resource =
643 Resource::parse_owner_repo(&format!("{}/{}", self.config.host, r.full_name))?;
644 let mut state = RepoState::new(resource, r.id);
645 state.visibility = match r.visibility.as_deref() {
646 Some("public") => Visibility::Public,
647 Some("internal") => Visibility::Internal,
648 Some("private") => Visibility::Private,
649 _ if r.private => Visibility::Private,
650 _ => Visibility::Public,
651 };
652 state.archived = r.archived;
653 state.default_branch = r.default_branch.clone();
654 Ok(state)
655 }
656
657 async fn collaborators(
658 &self,
659 token: &Secret,
660 owner: &str,
661 name: &str,
662 ) -> Result<Vec<CollaboratorJson>> {
663 let mut url = self.api.url(&["repos", owner, name, "collaborators"]);
664 url.query_pairs_mut().append_pair("affiliation", "direct");
665 self.api
666 .get_all(url, Auth::Bearer(token), "collaborators")
667 .await
668 }
669
670 async fn invitations(
671 &self,
672 token: &Secret,
673 owner: &str,
674 name: &str,
675 ) -> Result<Vec<InvitationJson>> {
676 let url = self.api.url(&["repos", owner, name, "invitations"]);
677 self.api
678 .get_all(url, Auth::Bearer(token), "invitations")
679 .await
680 }
681
682 async fn managed_ruleset(
683 &self,
684 token: &Secret,
685 owner: &str,
686 name: &str,
687 ) -> Result<Option<RulesetJson>> {
688 let mut url = self.api.url(&["repos", owner, name, "rulesets"]);
689 url.query_pairs_mut()
690 .append_pair("includes_parents", "false");
691 let list: Vec<RulesetSummary> = self
692 .api
693 .get_all(url, Auth::Bearer(token), "rulesets")
694 .await?;
695 let Some(summary) = list.into_iter().find(|r| r.name == RULESET_NAME) else {
696 return Ok(None);
697 };
698 let url = self
699 .api
700 .url(&["repos", owner, name, "rulesets", &summary.id.to_string()]);
701 self.api.get_opt(url, Auth::Bearer(token), "ruleset").await
702 }
703
704 fn protection(
705 &self,
706 rs: &RulesetJson,
707 default_branch: Option<&str>,
708 actions_id: Option<u64>,
709 ) -> ProtectionState {
710 protection_of(rs, default_branch, actions_id)
711 }
712
713 async fn write_file(
716 &self,
717 repo: &Resource,
718 path: &str,
719 contents: &[u8],
720 message: &str,
721 ) -> Result<StepOutcome> {
722 validate_repo_path(path)?;
723 let (token, owner, name) = self.repo_token(repo, PERMS_CONTENTS).await?;
724 self.write_file_with(&token, &owner, &name, path, contents, message)
725 .await
726 }
727
728 async fn write_file_with(
730 &self,
731 token: &Secret,
732 owner: &str,
733 name: &str,
734 path: &str,
735 contents: &[u8],
736 message: &str,
737 ) -> Result<StepOutcome> {
738 validate_repo_path(path)?;
739 let mut segments = vec!["repos", owner, name, "contents"];
740 segments.extend(path.split('/'));
741 let url = self.api.url(&segments);
742
743 let existing: Option<Value> = self
746 .api
747 .get_opt(url.clone(), Auth::Bearer(token), path)
748 .await?;
749 let existing = match existing {
750 Some(Value::Array(_)) => {
751 return Err(ForgeError::Rejected {
752 status: 409,
753 message: format!("`{path}` exists and is a directory, not a file"),
754 });
755 }
756 Some(v) => Some(
757 serde_json::from_value::<ContentJson>(v)
758 .map_err(|e| ForgeError::Protocol(format!("{path}: {e}")))?,
759 ),
760 None => None,
761 };
762 let sha = match existing {
763 Some(c) if c.kind != "file" => {
764 return Err(ForgeError::Rejected {
765 status: 409,
766 message: format!("`{path}` exists and is a {}, not a file", c.kind),
767 });
768 }
769 Some(c) => {
770 if decode_content(&c)? == contents {
771 return Ok(StepOutcome::Unchanged);
772 }
773 Some(c.sha)
774 }
775 None => None,
776 };
777
778 let mut body = json!({ "message": message, "content": STANDARD.encode(contents) });
779 if let Some(sha) = &sha {
780 body["sha"] = json!(sha);
781 }
782 self.api
783 .send(Method::PUT, url, Auth::Bearer(token), Some(&body), path)
784 .await
785 .map_err(|e| match e {
786 ForgeError::Rejected { status, message } => ForgeError::Rejected {
787 status,
788 message: format!(
789 "{message} — if the default branch is already protected, this file can \
790 only change through a pull request (the ruleset has no bypass actors, \
791 by design)"
792 ),
793 },
794 e => e,
795 })?;
796 Ok(if sha.is_some() {
797 StepOutcome::Updated
798 } else {
799 StepOutcome::Created
800 })
801 }
802
803 async fn set_variable(&self, repo: &Resource, var: &str, value: &str) -> Result<StepOutcome> {
804 check_variable_name(var)?;
805 let (token, owner, name) = self.repo_token(repo, PERMS_VARIABLES).await?;
806 let url = self
807 .api
808 .url(&["repos", &owner, &name, "actions", "variables", var]);
809 #[derive(Deserialize)]
810 struct Variable {
811 value: String,
812 }
813 let body = json!({ "name": var, "value": value });
814 match self
815 .api
816 .get_opt::<Variable>(url.clone(), Auth::Bearer(&token), var)
817 .await?
818 {
819 Some(v) if v.value == value => Ok(StepOutcome::Unchanged),
820 Some(_) => {
821 self.api
822 .send(Method::PATCH, url, Auth::Bearer(&token), Some(&body), var)
823 .await?;
824 Ok(StepOutcome::Updated)
825 }
826 None => {
827 let url = self
828 .api
829 .url(&["repos", &owner, &name, "actions", "variables"]);
830 self.api
831 .send(Method::POST, url, Auth::Bearer(&token), Some(&body), var)
832 .await?;
833 Ok(StepOutcome::Created)
834 }
835 }
836 }
837
838 async fn protect(&self, repo: &Resource, spec: &ProtectionSpec) -> Result<StepOutcome> {
839 let (ns, _, _) = self.locate(repo)?;
840 let (token, owner, name) = self.repo_token(repo, PERMS_ADMIN).await?;
841 let bridge_posted = self.capabilities(&ns).bridge_posted_check;
842 self.protect_with(&token, &owner, &name, spec, bridge_posted)
843 .await
844 }
845
846 async fn check_integration_id(&self, token: &Secret, bridge_posted: bool) -> Result<u64> {
849 if bridge_posted {
850 Ok(self.config.app_id)
851 } else {
852 self.actions_app_id(token).await
853 }
854 }
855
856 async fn protect_with(
860 &self,
861 token: &Secret,
862 owner: &str,
863 name: &str,
864 spec: &ProtectionSpec,
865 bridge_posted: bool,
866 ) -> Result<StepOutcome> {
867 let actions_id = if spec.require_status_check {
870 Some(self.check_integration_id(token, bridge_posted).await?)
871 } else {
872 None
873 };
874 let repo_json: RepoJson = self
875 .api
876 .json(
877 Method::GET,
878 self.api.url(&["repos", owner, name]),
879 Auth::Bearer(token),
880 None,
881 name,
882 )
883 .await?;
884 let body = ruleset_body(spec, actions_id);
885
886 match self.managed_ruleset(token, owner, name).await? {
887 Some(rs) => {
888 let observed =
889 self.protection(&rs, repo_json.default_branch.as_deref(), actions_id);
890 if satisfies(&observed, spec) && rules_match(&rs, spec) {
891 return Ok(StepOutcome::Unchanged);
892 }
893 let url = self
894 .api
895 .url(&["repos", owner, name, "rulesets", &rs.id.to_string()]);
896 self.api
897 .send(
898 Method::PUT,
899 url,
900 Auth::Bearer(token),
901 Some(&body),
902 "ruleset",
903 )
904 .await?;
905 Ok(StepOutcome::Updated)
906 }
907 None => {
908 let url = self.api.url(&["repos", owner, name, "rulesets"]);
909 self.api
910 .send(
911 Method::POST,
912 url,
913 Auth::Bearer(token),
914 Some(&body),
915 "ruleset",
916 )
917 .await?;
918 Ok(StepOutcome::Created)
919 }
920 }
921 }
922
923 async fn remove_file(&self, repo: &Resource, path: &str, message: &str) -> Result<StepOutcome> {
925 validate_repo_path(path)?;
926 let (token, owner, name) = self.repo_token(repo, PERMS_CONTENTS).await?;
927 let Some((_, sha)) = self.file_at(&token, &owner, &name, path, None).await? else {
928 return Ok(StepOutcome::Unchanged);
929 };
930 let mut segments = vec!["repos", owner.as_str(), name.as_str(), "contents"];
931 segments.extend(path.split('/'));
932 let body = json!({ "message": message, "sha": sha });
933 self.api
934 .send(
935 Method::DELETE,
936 self.api.url(&segments),
937 Auth::Bearer(&token),
938 Some(&body),
939 path,
940 )
941 .await
942 .map_err(|e| match e {
943 ForgeError::Rejected { status, message } => ForgeError::Rejected {
944 status,
945 message: format!(
946 "{message} — the default branch is protected, so this clean-up has to \
947 land through a pull request"
948 ),
949 },
950 e => e,
951 })?;
952 Ok(StepOutcome::Updated)
953 }
954
955 async fn remove_variable(&self, repo: &Resource, var: &str) -> Result<StepOutcome> {
957 check_variable_name(var)?;
958 let (token, owner, name) = self.repo_token(repo, PERMS_VARIABLES).await?;
959 let url = self
960 .api
961 .url(&["repos", &owner, &name, "actions", "variables", var]);
962 if self
963 .api
964 .get_opt::<Value>(url.clone(), Auth::Bearer(&token), var)
965 .await?
966 .is_none()
967 {
968 return Ok(StepOutcome::Unchanged);
969 }
970 self.api
971 .send(Method::DELETE, url, Auth::Bearer(&token), None, var)
972 .await?;
973 Ok(StepOutcome::Updated)
974 }
975
976 fn is_personal_owner(ns: &Namespace, id: u64) -> bool {
982 ns.kind == NamespaceKind::User && Some(id) == ns.owner_id
983 }
984
985 fn expressible(&self, ns: &Namespace, desired: &[RoleAssignment]) -> Vec<RoleAssignment> {
988 desired
989 .iter()
990 .filter(|a| !Self::is_personal_owner(ns, a.account.id))
991 .cloned()
992 .collect()
993 }
994
995 async fn login_for(&self, token: &Secret, id: u64) -> Result<String> {
996 let user: UserJson = self
999 .api
1000 .json(
1001 Method::GET,
1002 self.api.url(&["user", &id.to_string()]),
1003 Auth::Bearer(token),
1004 None,
1005 "user",
1006 )
1007 .await?;
1008 if user.id != id {
1009 return Err(ForgeError::Protocol(format!(
1010 "asked for user {id}, GitHub answered with {}",
1011 user.id
1012 )));
1013 }
1014 Ok(user.login)
1015 }
1016
1017 #[allow(clippy::too_many_arguments)]
1018 async fn change_role(
1019 &self,
1020 token: &Secret,
1021 ns: &Namespace,
1022 owner: &str,
1023 name: &str,
1024 account: &ForgeAccount,
1025 to: ForgeRole,
1026 current: Option<&Current>,
1027 ) -> Result<RoleOutcome> {
1028 let auth = Auth::Bearer(token);
1029 match (current, to) {
1030 (Some(Current::Invited { id, .. }), ForgeRole::None) => {
1031 let url = self
1032 .api
1033 .url(&["repos", owner, name, "invitations", &id.to_string()]);
1034 self.api
1035 .send(Method::DELETE, url, auth, None, "invitation")
1036 .await?;
1037 Ok(RoleOutcome::Applied)
1038 }
1039 (Some(Current::Member { login, .. }), ForgeRole::None) => {
1040 let url = self
1041 .api
1042 .url(&["repos", owner, name, "collaborators", login]);
1043 self.api
1044 .send(Method::DELETE, url, auth, None, "collaborator")
1045 .await?;
1046 Ok(RoleOutcome::Applied)
1047 }
1048 (None, ForgeRole::None) => Ok(RoleOutcome::Applied),
1049 (Some(Current::Invited { id, .. }), role) => {
1050 let url = self
1051 .api
1052 .url(&["repos", owner, name, "invitations", &id.to_string()]);
1053 let body = json!({ "permissions": invitation_permission(role) });
1054 self.api
1055 .send(Method::PATCH, url, auth, Some(&body), "invitation")
1056 .await?;
1057 Ok(RoleOutcome::Invited)
1058 }
1059 (_, role) => {
1060 let login = self.login_for(token, account.id).await?;
1061 let url = self
1062 .api
1063 .url(&["repos", owner, name, "collaborators", &login]);
1064 let body = (ns.kind == NamespaceKind::Organization)
1067 .then(|| json!({ "permission": put_permission(role) }));
1068 let resp = self
1069 .api
1070 .send(Method::PUT, url, auth, body.as_ref(), "collaborator")
1071 .await?;
1072 Ok(if resp.status() == reqwest::StatusCode::CREATED {
1073 RoleOutcome::Invited
1074 } else {
1075 RoleOutcome::Applied
1076 })
1077 }
1078 }
1079 }
1080
1081 async fn access_sources(
1088 &self,
1089 token: &Secret,
1090 owner: &str,
1091 name: &str,
1092 login: &str,
1093 ) -> Vec<AccessSource> {
1094 let auth = Auth::Bearer(token);
1095 let membership = |url| async move {
1096 self.api
1097 .get_opt::<MembershipJson>(url, auth, "membership")
1098 .await
1099 .ok()
1100 .flatten()
1101 .filter(|m| m.state == "active")
1102 };
1103 let mut via = Vec::new();
1104 let org = membership(self.api.url(&["orgs", owner, "memberships", login])).await;
1105 if org.as_ref().is_some_and(|m| m.role == "admin") {
1106 via.push(AccessSource::OrgOwner(owner.to_string()));
1107 }
1108 let teams: Vec<TeamJson> = self
1109 .api
1110 .get_all(
1111 self.api.url(&["repos", owner, name, "teams"]),
1112 auth,
1113 "repository teams",
1114 )
1115 .await
1116 .unwrap_or_default();
1117 for t in teams {
1118 let url = self
1119 .api
1120 .url(&["orgs", owner, "teams", &t.slug, "memberships", login]);
1121 if membership(url).await.is_some() {
1122 via.push(AccessSource::Team(t.name));
1123 }
1124 }
1125 if via.is_empty() && org.is_some() {
1126 via.push(AccessSource::OrgMember(owner.to_string()));
1127 }
1128 via
1129 }
1130}
1131
1132enum Current {
1134 Member { login: String, role: ForgeRole },
1135 Invited { id: u64, role: ForgeRole },
1136}
1137
1138impl Current {
1139 fn role(&self) -> ForgeRole {
1140 match self {
1141 Current::Member { role, .. } | Current::Invited { role, .. } => *role,
1142 }
1143 }
1144}
1145
1146#[async_trait]
1147impl Forge for GitHubForge {
1148 fn kind(&self) -> ForgeKind {
1149 ForgeKind::GitHub
1150 }
1151
1152 fn host(&self) -> &str {
1153 &self.config.host
1154 }
1155
1156 fn capabilities(&self, ns: &Namespace) -> Capabilities {
1157 let automated = ns.installation_id.is_some();
1158 let mut c = Capabilities::default();
1159 c.automation = automated;
1160 c.required_checks = RequiredCheckKind::Ruleset;
1161 c.account_link = LinkMethod::DeviceFlow;
1162 c.webhooks = automated;
1163 c.per_repo_tokens = automated;
1164 c.required_workflow = automated
1165 && ns.kind == NamespaceKind::Organization
1166 && self.required_workflow_known(&ns.resource);
1167 c.bridge_posted_check = automated
1171 && !c.required_workflow
1172 && self.config.bridge_checks
1173 && self.bridge_checks_ready(&ns.resource) == Some(true);
1174 c.single_owner_repos_unreviewed = !c.required_workflow && !c.bridge_posted_check;
1178 match ns.kind {
1179 NamespaceKind::User => {
1180 c.bot_can_create_repos = false;
1183 c.role_levels = USER_LADDER.to_vec();
1184 }
1185 _ => {
1186 c.bot_can_create_repos = automated;
1187 c.role_levels = ORG_LADDER.to_vec();
1188 }
1189 }
1190 c
1191 }
1192
1193 async fn begin_bind(&self, req: BindRequest) -> Result<BindStep> {
1194 if req.namespace.host() != self.config.host || !req.namespace.is_namespace() {
1195 return Err(ForgeError::WrongResource {
1196 resource: req.namespace.to_string(),
1197 expected: format!("a namespace on `{}`", self.config.host),
1198 });
1199 }
1200 if req.state.len() < MIN_STATE_LEN
1201 || !req
1202 .state
1203 .bytes()
1204 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1205 {
1206 return Err(ForgeError::Config(format!(
1207 "bind state must be at least {MIN_STATE_LEN} base64url characters from a CSPRNG \
1208 (see GitHubForge::new_state)"
1209 )));
1210 }
1211 let mut url = self
1212 .api
1213 .web_url(&["apps", &self.config.app_slug, "installations", "new"]);
1214 url.query_pairs_mut().append_pair("state", &req.state);
1215 Ok(BindStep::Redirect {
1216 url: url.to_string(),
1217 })
1218 }
1219
1220 async fn complete_bind(&self, cb: BindCallback) -> Result<NamespaceBinding> {
1221 let reject = |m: String| Err(ForgeError::BindRejected(m));
1222 let state = cb.params.get("state").map(String::as_str).unwrap_or("");
1223 if cb.expected_state.len() < MIN_STATE_LEN
1224 || aws_lc_rs::constant_time::verify_slices_are_equal(
1225 state.as_bytes(),
1226 cb.expected_state.as_bytes(),
1227 )
1228 .is_err()
1229 {
1230 return reject("the `state` does not match a bind this VTC started".into());
1231 }
1232 match cb.params.get("setup_action").map(String::as_str) {
1233 Some("install") | Some("update") | None => {}
1234 Some("request") => {
1235 return reject(
1236 "the installation was requested but an owner has not approved it yet".into(),
1237 );
1238 }
1239 Some(other) => return reject(format!("unexpected setup_action `{other}`")),
1240 }
1241 if cb.expected_namespace.host() != self.config.host || !cb.expected_namespace.is_namespace()
1242 {
1243 return reject(format!(
1244 "`{}` is not a namespace on `{}`",
1245 cb.expected_namespace, self.config.host
1246 ));
1247 }
1248 let installation_id: u64 = match cb.params.get("installation_id").map(|s| s.parse()) {
1249 Some(Ok(id)) => id,
1250 _ => return reject("missing or malformed `installation_id`".into()),
1251 };
1252
1253 let jwt = self.jwt().await?;
1256 let url = self
1257 .api
1258 .url(&["app", "installations", &installation_id.to_string()]);
1259 let inst: InstallationJson = match self
1260 .api
1261 .json(Method::GET, url, Auth::Bearer(&jwt), None, "installation")
1262 .await
1263 {
1264 Ok(i) => i,
1265 Err(ForgeError::NotFound { .. }) => {
1266 return reject(format!(
1267 "installation {installation_id} is not an installation of this App"
1268 ));
1269 }
1270 Err(e) => return Err(e),
1271 };
1272 if inst.id != installation_id {
1273 return reject("GitHub returned a different installation".into());
1274 }
1275 if !inst
1276 .account
1277 .login
1278 .eq_ignore_ascii_case(cb.expected_namespace.owner())
1279 {
1280 return reject(format!(
1281 "the App was installed on `{}`, but the bind was for `{}`",
1282 inst.account.login, cb.expected_namespace
1283 ));
1284 }
1285 if inst.suspended_at.is_some() {
1286 return reject("the installation is suspended".into());
1287 }
1288 let kind = match inst.account.kind.as_str() {
1289 "Organization" => NamespaceKind::Organization,
1290 "User" => NamespaceKind::User,
1291 other => return reject(format!("unsupported account type `{other}`")),
1292 };
1293 let namespace = Namespace::new(cb.expected_namespace.clone(), kind)
1294 .with_owner_id(inst.account.id)
1295 .with_installation(installation_id);
1296 let probed = match self.probe_org_rulesets(&namespace).await {
1300 Ok(available) => {
1301 self.set_required_workflow(&namespace.resource, available);
1302 true
1303 }
1304 Err(e) => {
1305 tracing::warn!(error = %e, "could not tell whether org rulesets are available");
1306 false
1307 }
1308 };
1309 self.set_bridge_checks_ready(
1313 &namespace.resource,
1314 crate::manifest::check_ready(&inst.permissions, &inst.events),
1315 );
1316 let binding = NamespaceBinding::new(namespace, installation_missing(&inst));
1317 Ok(if probed {
1320 let caps = self.capabilities(&binding.namespace);
1321 binding.with_capabilities(caps)
1322 } else {
1323 binding
1324 })
1325 }
1326
1327 async fn begin_account_link(&self, member: &str) -> Result<LinkStep> {
1328 tracing::debug!(member, "starting GitHub device flow");
1329 let url = self.api.web_url(&["login", "device", "code"]);
1330 let resp: DeviceCodeJson = self
1331 .api
1332 .oauth(url, &json!({ "client_id": self.config.client_id }))
1333 .await?;
1334 if let Some(err) = resp.error {
1335 return Err(ForgeError::LinkFailed(format!(
1336 "{err}: {}",
1337 resp.error_description.unwrap_or_default()
1338 )));
1339 }
1340 let missing = || ForgeError::Protocol("device code response is incomplete".into());
1341 Ok(LinkStep::DeviceCode {
1342 device_code: resp.device_code.ok_or_else(missing)?,
1343 user_code: resp.user_code.ok_or_else(missing)?,
1344 verification_uri: resp.verification_uri.ok_or_else(missing)?,
1345 expires_in: resp.expires_in.ok_or_else(missing)?,
1346 interval: resp.interval.unwrap_or(5),
1347 })
1348 }
1349
1350 async fn complete_account_link(&self, cb: LinkCallback) -> Result<ForgeAccount> {
1351 let LinkCallback::DeviceCode {
1352 device_code,
1353 mut interval,
1354 expires_in,
1355 } = cb
1356 else {
1357 return Err(ForgeError::Unsupported {
1358 operation: "redirect account link".into(),
1359 hint: "GitHub links accounts through the device flow".into(),
1360 });
1361 };
1362 let url = self.api.web_url(&["login", "oauth", "access_token"]);
1363 let body = json!({
1364 "client_id": self.config.client_id,
1365 "device_code": device_code,
1366 "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
1367 });
1368 let expires_in = expires_in.min(DEVICE_CODE_MAX_LIFETIME_SECS);
1371 let mut waited = 0u64;
1372 let token = loop {
1373 if waited >= expires_in {
1374 return Err(ForgeError::LinkFailed(
1375 "the device code expired before the member approved; start again".into(),
1376 ));
1377 }
1378 tokio::time::sleep(self.config.device_poll_unit * interval.max(1) as u32).await;
1379 waited += interval.max(1);
1380 let poll: TokenPollJson = self.api.oauth(url.clone(), &body).await?;
1381 if let Some(token) = poll.access_token {
1382 break Secret::new(token);
1383 }
1384 match next_poll(interval, &poll)? {
1385 Some(next) => interval = next,
1386 None => unreachable!("next_poll returns Some or Err when there is no token"),
1387 }
1388 };
1389
1390 let user: UserJson = self
1391 .api
1392 .json(
1393 Method::GET,
1394 self.api.url(&["user"]),
1395 Auth::Bearer(&token),
1396 None,
1397 "authenticated user",
1398 )
1399 .await?;
1400 self.revoke_user_token(&token).await;
1404 Ok(ForgeAccount::new(user.id, user.login))
1405 }
1406
1407 async fn inspect(&self, repo: &Resource) -> Result<RepoState> {
1408 let (ns, _, _) = self.locate(repo)?;
1409 let (token, owner, name) = self.repo_token(repo, PERMS_ADMIN).await?;
1410 let auth = Auth::Bearer(&token);
1411 let r: RepoJson = self
1412 .api
1413 .json(
1414 Method::GET,
1415 self.api.url(&["repos", &owner, &name]),
1416 auth,
1417 None,
1418 repo.as_str(),
1419 )
1420 .await?;
1421 let mut state = self.repo_state(&r)?;
1422
1423 for c in self.collaborators(&token, &owner, &name).await? {
1424 if Self::is_personal_owner(&ns, c.id) {
1425 continue;
1426 }
1427 let role = c.role();
1428 state
1429 .collaborators
1430 .push(Collaborator::new(ForgeAccount::new(c.id, c.login), role));
1431 }
1432 for i in self.invitations(&token, &owner, &name).await? {
1433 if let Some(user) = i.invitee {
1434 state.collaborators.push(Collaborator::invited(
1435 ForgeAccount::new(user.id, user.login),
1436 role_from_name(&i.permissions).unwrap_or(ForgeRole::Read),
1437 ));
1438 }
1439 }
1440 let caps = self.capabilities(&ns);
1441 let rs = self.managed_ruleset(&token, &owner, &name).await?;
1442 if let Some(rs) = &rs {
1443 let check_app = self
1445 .check_integration_id(&token, caps.bridge_posted_check)
1446 .await?;
1447 state.protection = self.protection(rs, r.default_branch.as_deref(), Some(check_app));
1448 }
1449 if !caps.bridge_posted_check {
1450 self.inspect_actions_policy(&token, &owner, &name, &mut state.protection)
1452 .await?;
1453 }
1454 drop(token);
1455 if caps.bridge_posted_check {
1456 state.protection.check_source_guard = vgi_forge::CheckSourceGuard::BridgePosted;
1459 } else if caps.required_workflow {
1460 self.inspect_required_workflow(&ns, r.id, &mut state.protection)
1461 .await?;
1462 } else if caps.automation {
1463 self.inspect_owner_review(
1464 repo,
1465 &owner,
1466 &name,
1467 rs.as_ref(),
1468 r.default_branch.as_deref(),
1469 &mut state.protection,
1470 )
1471 .await?;
1472 }
1473 Ok(state)
1474 }
1475
1476 async fn create_repo(&self, spec: &RepoSpec) -> Result<RepoState> {
1477 let (ns, owner, name) = self.locate(&spec.resource)?;
1478 if !self.capabilities(&ns).bot_can_create_repos {
1479 return Err(ForgeError::Unsupported {
1480 operation: "repository creation".into(),
1481 hint: format!(
1482 "the bridge cannot create repositories in `{}`; the account holder runs \
1483 `gh repo create {owner}/{name}` and `vgi repo init`, then the repo is adopted",
1484 ns.resource
1485 ),
1486 });
1487 }
1488 let token = self.installation_token(&ns, None, PERMS_ADMIN).await?;
1495 let auth = Auth::Bearer(&token);
1496 if let Some(existing) = self
1497 .api
1498 .get_opt::<RepoJson>(
1499 self.api.url(&["repos", owner, name]),
1500 auth,
1501 spec.resource.as_str(),
1502 )
1503 .await?
1504 {
1505 return Err(ForgeError::AlreadyExists {
1506 resource: spec.resource.to_string(),
1507 forge_id: Some(existing.id),
1508 });
1509 }
1510 let mut body = json!({
1511 "name": name,
1512 "visibility": match spec.visibility {
1513 Visibility::Private => "private",
1514 Visibility::Internal => "internal",
1515 _ => "public",
1516 },
1517 "auto_init": true,
1520 });
1521 if let Some(d) = &spec.description {
1522 body["description"] = json!(d);
1523 }
1524 let created: RepoJson = self
1525 .api
1526 .json(
1527 Method::POST,
1528 self.api.url(&["orgs", owner, "repos"]),
1529 auth,
1530 Some(&body),
1531 spec.resource.as_str(),
1532 )
1533 .await
1534 .map_err(|e| match e {
1535 ForgeError::Rejected {
1536 status: 422,
1537 message,
1538 } if message.contains("already exists") => ForgeError::AlreadyExists {
1539 resource: spec.resource.to_string(),
1540 forge_id: None,
1541 },
1542 e => e,
1543 })?;
1544 self.repo_state(&created)
1545 }
1546
1547 async fn archive_repo(&self, repo: &Resource) -> Result<()> {
1548 let (token, owner, name) = self.repo_token(repo, PERMS_ADMIN).await?;
1549 let url = self.api.url(&["repos", &owner, &name]);
1550 let r: RepoJson = self
1551 .api
1552 .json(
1553 Method::GET,
1554 url.clone(),
1555 Auth::Bearer(&token),
1556 None,
1557 repo.as_str(),
1558 )
1559 .await?;
1560 if let Some(set) = self
1561 .managed
1562 .write()
1563 .expect("lock poisoned")
1564 .get_mut(&repo.namespace())
1565 {
1566 set.remove(&r.id);
1569 }
1570 if r.archived {
1571 return Ok(());
1572 }
1573 self.api
1574 .send(
1575 Method::PATCH,
1576 url,
1577 Auth::Bearer(&token),
1578 Some(&json!({ "archived": true })),
1579 repo.as_str(),
1580 )
1581 .await?;
1582 Ok(())
1583 }
1584
1585 async fn apply_roles(
1586 &self,
1587 repo: &Resource,
1588 desired: &[RoleAssignment],
1589 unlisted: Unlisted,
1590 ) -> Result<ApplyReport> {
1591 let (ns, owner, name) = self.locate(repo)?;
1592 let ladder = self.capabilities(&ns).role_levels;
1593 let desired = self.expressible(&ns, desired);
1594 let mut wanted: BTreeMap<u64, (ForgeAccount, ForgeRole)> = BTreeMap::new();
1595 for a in &desired {
1596 let role = collapse_to_ladder(a.role, &ladder);
1599 if let Some((_, prev)) = wanted.insert(a.account.id, (a.account.clone(), role))
1600 && prev != role
1601 {
1602 return Err(ForgeError::Config(format!(
1603 "account {} is assigned two different roles",
1604 a.account.id
1605 )));
1606 }
1607 }
1608
1609 let token = self
1610 .installation_token(&ns, Some(name), PERMS_ADMIN)
1611 .await?;
1612 let mut current: BTreeMap<u64, (ForgeAccount, Current)> = BTreeMap::new();
1613 for c in self.collaborators(&token, owner, name).await? {
1614 if Self::is_personal_owner(&ns, c.id) {
1615 continue;
1616 }
1617 let role = c.role();
1618 current.insert(
1619 c.id,
1620 (
1621 ForgeAccount::new(c.id, c.login.clone()),
1622 Current::Member {
1623 login: c.login,
1624 role,
1625 },
1626 ),
1627 );
1628 }
1629 for i in self.invitations(&token, owner, name).await? {
1630 if let Some(user) = i.invitee {
1631 let role = role_from_name(&i.permissions).unwrap_or(ForgeRole::Read);
1632 current.entry(user.id).or_insert((
1633 ForgeAccount::new(user.id, user.login),
1634 Current::Invited { id: i.id, role },
1635 ));
1636 }
1637 }
1638
1639 let mut report = ApplyReport::default();
1640 let mut todo: Vec<(ForgeAccount, ForgeRole)> = Vec::new();
1641 for (id, (account, role)) in &wanted {
1642 let have = current.get(id).map_or(ForgeRole::None, |(_, c)| c.role());
1643 if have == *role {
1644 if *role != ForgeRole::None {
1645 report.unchanged.push(account.clone());
1646 }
1647 } else {
1648 todo.push((account.clone(), *role));
1649 }
1650 }
1651 for (id, (account, c)) in ¤t {
1652 if wanted.contains_key(id) {
1653 continue;
1654 }
1655 match unlisted {
1656 Unlisted::Remove => todo.push((account.clone(), ForgeRole::None)),
1657 _ => {
1658 let mut collab = Collaborator::new(account.clone(), c.role());
1659 collab.pending = matches!(c, Current::Invited { .. });
1660 report.kept_unlisted.push(collab);
1661 }
1662 }
1663 }
1664
1665 let own_bot = format!("{}[bot]", self.config.app_slug);
1668 for (account, to) in todo {
1669 let cur = current.get(&account.id).map(|(_, c)| c);
1670 let from = cur.map_or(ForgeRole::None, Current::role);
1671 if to == ForgeRole::None
1672 && let Some(Current::Member { login, .. }) = cur
1673 && login.eq_ignore_ascii_case(&own_bot)
1674 {
1675 report.changes.push(RoleChange::new(
1676 account,
1677 from,
1678 to,
1679 RoleOutcome::Failed("the bridge's own App is never removed".into()),
1680 ));
1681 continue;
1682 }
1683 let outcome = match self
1684 .change_role(&token, &ns, owner, name, &account, to, cur)
1685 .await
1686 {
1687 Ok(o) => o,
1688 Err(e @ (ForgeError::Unauthorized(_) | ForgeError::RateLimited { .. })) => {
1691 return Err(e);
1692 }
1693 Err(e) => RoleOutcome::Failed(e.to_string()),
1694 };
1695 report
1696 .changes
1697 .push(RoleChange::new(account, from, to, outcome));
1698 }
1699 Ok(report)
1700 }
1701
1702 async fn indirect_access(
1706 &self,
1707 repo: &Resource,
1708 account: &ForgeAccount,
1709 ) -> Result<Option<IndirectAccess>> {
1710 let (ns, owner, name) = self.locate(repo)?;
1711 let org = ns.kind == NamespaceKind::Organization;
1712 let perms = if org { PERMS_ACCESS_ORG } else { PERMS_ACCESS };
1713 let token = self.installation_token(&ns, Some(name), perms).await?;
1714 let login = match self.login_for(&token, account.id).await {
1715 Ok(l) => l,
1716 Err(ForgeError::NotFound { .. }) => return Ok(None),
1718 Err(e) => return Err(e),
1719 };
1720 let url = self
1721 .api
1722 .url(&["repos", owner, name, "collaborators", &login, "permission"]);
1723 let Some(p) = self
1724 .api
1725 .get_opt::<PermissionJson>(url, Auth::Bearer(&token), "collaborator permission")
1726 .await?
1727 else {
1728 return Ok(None);
1729 };
1730 let role = p
1731 .role_name
1732 .as_deref()
1733 .and_then(role_from_name)
1734 .or_else(|| role_from_name(&p.permission))
1735 .unwrap_or(ForgeRole::None);
1736 if role == ForgeRole::None {
1737 return Ok(None);
1738 }
1739 if role == ForgeRole::Read {
1740 let r: RepoJson = self
1743 .api
1744 .json(
1745 Method::GET,
1746 self.api.url(&["repos", owner, name]),
1747 Auth::Bearer(&token),
1748 None,
1749 repo.as_str(),
1750 )
1751 .await?;
1752 if self.repo_state(&r)?.visibility != Visibility::Private {
1753 return Ok(None);
1754 }
1755 }
1756 let via = if org {
1757 self.access_sources(&token, owner, name, &login).await
1758 } else {
1759 Vec::new()
1760 };
1761 Ok(Some(IndirectAccess::new(role, via)))
1762 }
1763
1764 fn bootstrap_plan(&self, repo: &RepoSpec, cfg: &VgiConfig) -> Result<Vec<BootstrapStep>> {
1765 if repo.resource.host() != self.config.host {
1766 return Err(ForgeError::WrongResource {
1767 resource: repo.resource.to_string(),
1768 expected: format!("a repository on `{}`", self.config.host),
1769 });
1770 }
1771 repo.resource.require_owner_repo()?;
1772 let ns = self.namespace(&repo.resource.namespace())?;
1773 let guard = self.check_guard(&ns, repo);
1774 *self.verify_trust_action.lock().expect("lock poisoned") =
1775 Some(cfg.verify_trust_action.clone());
1776 github_plan(repo, cfg, &self.config.checkout_action, &guard)
1777 }
1778
1779 async fn run_step(&self, repo: &Resource, step: &BootstrapStep) -> Result<StepOutcome> {
1780 match &step.action {
1781 StepAction::WriteFile {
1782 path,
1783 contents,
1784 message,
1785 } => self.write_file(repo, path, contents, message).await,
1786 StepAction::SetVariable { name, value } => self.set_variable(repo, name, value).await,
1787 StepAction::ProtectDefaultBranch(spec) => self.protect(repo, spec).await,
1788 StepAction::RequireNamespaceWorkflow {
1789 contents,
1790 check,
1791 message,
1792 } => {
1793 self.require_namespace_workflow(repo, contents, check, message)
1794 .await
1795 }
1796 StepAction::RequireOwnerReview {
1797 paths,
1798 owners,
1799 community_rules,
1800 message,
1801 } => {
1802 self.require_owner_review(repo, paths, owners, community_rules, message)
1803 .await
1804 }
1805 StepAction::RemoveFile { path, message } => self.remove_file(repo, path, message).await,
1806 StepAction::RemoveVariable { name } => self.remove_variable(repo, name).await,
1807 other => Err(ForgeError::Unsupported {
1808 operation: format!("bootstrap step {other:?}"),
1809 hint: "this GitHub adapter does not know that step".into(),
1810 }),
1811 }
1812 }
1813
1814 fn parse_event(&self, headers: &HeaderMap, body: &[u8]) -> Result<Option<ForgeEvent>> {
1815 webhook::parse(&self.webhook_secret, &self.config.host, headers, body)
1816 }
1817
1818 fn diff(&self, observed: &RepoState, desired: &Projection) -> Vec<Drift> {
1824 let mut drift = default_diff(observed, desired);
1825 if desired.required_check.is_some() {
1826 let ns = self.namespace(&desired.resource.namespace()).ok();
1827 drift.extend(guard::owner_review_drift(ns.as_ref(), observed, desired));
1828 }
1829 guard::merge_protection_drift(drift)
1830 }
1831}
1832
1833impl ForgeHooks for GitHubForge {
1834 fn before_apply_roles(
1839 &self,
1840 repo: &Resource,
1841 desired: &[RoleAssignment],
1842 ) -> HookDecision<Vec<RoleAssignment>> {
1843 let Ok(ns) = self.namespace(&repo.namespace()) else {
1844 return HookDecision::Continue;
1845 };
1846 let kept = self.expressible(&ns, desired);
1847 if kept.len() == desired.len() {
1848 HookDecision::Continue
1849 } else {
1850 HookDecision::Modify(kept)
1851 }
1852 }
1853}
1854
1855fn protection_of(
1859 rs: &RulesetJson,
1860 default_branch: Option<&str>,
1861 actions_id: Option<u64>,
1862) -> ProtectionState {
1863 let mut p = ProtectionState::default();
1864 p.present = true;
1865 p.enforced = rs.enforcement == "active";
1866
1867 let refs = |key: &str| -> Vec<String> {
1868 rs.conditions
1869 .as_ref()
1870 .and_then(|c| c.get("ref_name"))
1871 .and_then(|r| r.get(key))
1872 .and_then(Value::as_array)
1873 .map(|a| {
1874 a.iter()
1875 .filter_map(Value::as_str)
1876 .map(str::to_string)
1877 .collect()
1878 })
1879 .unwrap_or_default()
1880 };
1881 let mut default_names = vec!["~DEFAULT_BRANCH".to_string(), "~ALL".to_string()];
1882 if let Some(b) = default_branch {
1883 default_names.push(format!("refs/heads/{b}"));
1884 }
1885 let (include, exclude) = (refs("include"), refs("exclude"));
1886 p.covers_default_branch = rs.target.as_deref().unwrap_or("branch") == "branch"
1890 && include.iter().any(|r| default_names.contains(r))
1891 && exclude.is_empty();
1892
1893 for rule in &rs.rules {
1894 match rule.kind.as_str() {
1895 "pull_request" => p.requires_pull_request = true,
1896 "non_fast_forward" => p.blocks_force_push = true,
1897 "deletion" => p.blocks_deletion = true,
1898 "required_status_checks" => {
1899 let checks = rule
1900 .parameters
1901 .as_ref()
1902 .and_then(|v| v.get("required_status_checks"))
1903 .and_then(Value::as_array)
1904 .cloned()
1905 .unwrap_or_default();
1906 p.required_checks.extend(checks.iter().filter_map(|c| {
1910 let pinned = actions_id.is_some()
1911 && c.get("integration_id").and_then(Value::as_u64) == actions_id;
1912 pinned
1913 .then(|| c.get("context").and_then(Value::as_str))
1914 .flatten()
1915 .map(str::to_string)
1916 }));
1917 }
1918 _ => {}
1919 }
1920 }
1921
1922 match &rs.bypass_actors {
1923 Some(actors) => {
1924 p.bypass_actors = actors
1925 .iter()
1926 .map(|a| {
1927 format!(
1928 "{}:{}:{}",
1929 a.actor_type,
1930 a.actor_id.map_or_else(|| "-".into(), |i| i.to_string()),
1931 a.bypass_mode.as_deref().unwrap_or("always")
1932 )
1933 })
1934 .collect()
1935 }
1936 None => p.bypass_actors = vec!["<bypass actors not visible to the bridge>".into()],
1938 }
1939 if let Some(mode) = rs.current_user_can_bypass.as_deref()
1940 && mode != "never"
1941 {
1942 p.bypass_actors.push(format!("bridge-app:{mode}"));
1943 }
1944 p
1945}
1946
1947pub fn ruleset_body(spec: &ProtectionSpec, actions_id: Option<u64>) -> Value {
1954 let mut rules = Vec::new();
1955 if spec.block_deletion {
1956 rules.push(json!({ "type": "deletion" }));
1957 }
1958 if spec.block_force_push {
1959 rules.push(json!({ "type": "non_fast_forward" }));
1960 }
1961 if spec.require_pull_request {
1962 rules.push(json!({
1963 "type": "pull_request",
1964 "parameters": {
1965 "required_approving_review_count": u8::from(spec.require_code_owner_review),
1970 "dismiss_stale_reviews_on_push": spec.require_code_owner_review,
1971 "require_code_owner_review": spec.require_code_owner_review,
1972 "require_last_push_approval": spec.require_code_owner_review,
1973 "required_review_thread_resolution": false,
1974 }
1975 }));
1976 }
1977 if let (true, Some(actions_id)) = (spec.require_status_check, actions_id) {
1978 rules.push(json!({
1979 "type": "required_status_checks",
1980 "parameters": {
1981 "strict_required_status_checks_policy": false,
1982 "required_status_checks": [
1983 { "context": spec.required_check, "integration_id": actions_id }
1984 ],
1985 }
1986 }));
1987 }
1988 json!({
1989 "name": RULESET_NAME,
1990 "target": "branch",
1991 "enforcement": "active",
1992 "bypass_actors": [],
1993 "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
1994 "rules": rules,
1995 })
1996}
1997
1998pub fn ruleset_satisfies(
2005 ruleset: &Value,
2006 default_branch: Option<&str>,
2007 actions_id: Option<u64>,
2008 spec: &ProtectionSpec,
2009) -> Result<bool> {
2010 let rs: RulesetJson = serde_json::from_value(ruleset.clone())
2011 .map_err(|e| ForgeError::Protocol(format!("ruleset: {e}")))?;
2012 let observed = protection_of(&rs, default_branch, actions_id);
2013 Ok(satisfies(&observed, spec) && rules_match(&rs, spec))
2014}
2015
2016fn check_variable_name(var: &str) -> Result<()> {
2017 if var.is_empty()
2018 || !var
2019 .bytes()
2020 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
2021 {
2022 return Err(ForgeError::Config(format!(
2023 "variable name `{var}` must be [A-Z0-9_]"
2024 )));
2025 }
2026 Ok(())
2027}
2028
2029fn rules_match(rs: &RulesetJson, spec: &ProtectionSpec) -> bool {
2034 let has_status = rs.rules.iter().any(|r| r.kind == "required_status_checks");
2035 let review = spec.require_code_owner_review;
2036 let pr_ok = !spec.require_pull_request
2037 || rs.rules.iter().any(|r| {
2038 let param = |k: &str| r.parameters.as_ref().and_then(|p| p.get(k)).cloned();
2039 let flag = |k: &str| param(k).and_then(|v| v.as_bool()).unwrap_or(false);
2040 r.kind == "pull_request"
2041 && param("required_approving_review_count")
2042 .and_then(|v| v.as_u64())
2043 .unwrap_or(0)
2044 == u64::from(review)
2045 && flag("require_code_owner_review") == review
2046 && flag("dismiss_stale_reviews_on_push") == review
2047 && flag("require_last_push_approval") == review
2048 });
2049 has_status == spec.require_status_check && pr_ok
2050}
2051
2052fn satisfies(observed: &ProtectionState, spec: &ProtectionSpec) -> bool {
2053 observed.present
2054 && observed.enforced
2055 && observed.covers_default_branch
2056 && observed.bypass_actors.is_empty()
2057 && (!spec.require_status_check || observed.required_checks.contains(&spec.required_check))
2058 && (!spec.require_pull_request || observed.requires_pull_request)
2059 && (!spec.block_force_push || observed.blocks_force_push)
2060 && (!spec.block_deletion || observed.blocks_deletion)
2061}
2062
2063pub(crate) fn next_poll(interval: u64, poll: &TokenPollJson) -> Result<Option<u64>> {
2069 match poll.error.as_deref() {
2070 Some("authorization_pending") => Ok(Some(interval)),
2071 Some("slow_down") => Ok(Some(
2072 poll.interval.unwrap_or(interval + 5).max(interval + 5),
2073 )),
2074 Some("expired_token") => Err(ForgeError::LinkFailed(
2075 "the device code expired before the member approved; start again".into(),
2076 )),
2077 Some("access_denied") => Err(ForgeError::LinkFailed(
2078 "the member declined the authorisation".into(),
2079 )),
2080 Some(other) => Err(ForgeError::LinkFailed(format!(
2081 "{other}: {}",
2082 poll.error_description.as_deref().unwrap_or("")
2083 ))),
2084 None => Err(ForgeError::Protocol(
2085 "token response has neither a token nor an error".into(),
2086 )),
2087 }
2088}
2089
2090fn decode_content(c: &ContentJson) -> Result<Vec<u8>> {
2091 match c.encoding.as_deref() {
2092 Some("base64") => {
2093 let compact: String = c
2094 .content
2095 .as_deref()
2096 .unwrap_or("")
2097 .chars()
2098 .filter(|ch| !ch.is_whitespace())
2099 .collect();
2100 STANDARD
2101 .decode(compact)
2102 .map_err(|e| ForgeError::Protocol(format!("file content: {e}")))
2103 }
2104 Some("none") => Err(ForgeError::Rejected {
2109 status: 409,
2110 message: "the existing file is too large for GitHub to return inline (over 1 MB); \
2111 it was not written by the bootstrap — remove or rename it"
2112 .into(),
2113 }),
2114 other => Err(ForgeError::Protocol(format!(
2115 "file content in unknown encoding {other:?}"
2116 ))),
2117 }
2118}
2119
2120fn role_from_name(name: &str) -> Option<ForgeRole> {
2121 Some(match name {
2122 "admin" => ForgeRole::Admin,
2123 "maintain" => ForgeRole::Maintain,
2124 "write" | "push" => ForgeRole::Write,
2125 "triage" => ForgeRole::Triage,
2126 "read" | "pull" => ForgeRole::Read,
2127 _ => return None,
2128 })
2129}
2130
2131fn put_permission(role: ForgeRole) -> &'static str {
2132 match role {
2133 ForgeRole::Admin => "admin",
2134 ForgeRole::Maintain => "maintain",
2135 ForgeRole::Write => "push",
2136 ForgeRole::Triage => "triage",
2137 _ => "pull",
2138 }
2139}
2140
2141fn invitation_permission(role: ForgeRole) -> &'static str {
2142 match role {
2143 ForgeRole::Admin => "admin",
2144 ForgeRole::Maintain => "maintain",
2145 ForgeRole::Write => "write",
2146 ForgeRole::Triage => "triage",
2147 _ => "read",
2148 }
2149}
2150
2151#[derive(Deserialize)]
2154struct RepoJson {
2155 id: u64,
2156 full_name: String,
2157 #[serde(default)]
2158 visibility: Option<String>,
2159 #[serde(default)]
2160 private: bool,
2161 #[serde(default)]
2162 archived: bool,
2163 #[serde(default)]
2164 default_branch: Option<String>,
2165}
2166
2167#[derive(Deserialize)]
2168struct UserJson {
2169 id: u64,
2170 login: String,
2171}
2172
2173#[derive(Deserialize)]
2176struct PermissionJson {
2177 #[serde(default)]
2178 permission: String,
2179 #[serde(default)]
2180 role_name: Option<String>,
2181}
2182
2183#[derive(Deserialize)]
2185struct MembershipJson {
2186 #[serde(default)]
2187 state: String,
2188 #[serde(default)]
2189 role: String,
2190}
2191
2192#[derive(Deserialize)]
2193struct TeamJson {
2194 name: String,
2195 slug: String,
2196}
2197
2198#[derive(Deserialize)]
2199struct CollaboratorJson {
2200 id: u64,
2201 login: String,
2202 #[serde(default)]
2203 role_name: Option<String>,
2204 #[serde(default)]
2205 permissions: Option<PermsJson>,
2206}
2207
2208impl CollaboratorJson {
2209 fn role(&self) -> ForgeRole {
2211 if let Some(role) = self.role_name.as_deref().and_then(role_from_name) {
2212 return role;
2213 }
2214 let p = self.permissions.as_ref();
2215 let has = |f: fn(&PermsJson) -> bool| p.is_some_and(f);
2216 if has(|p| p.admin) {
2217 ForgeRole::Admin
2218 } else if has(|p| p.maintain) {
2219 ForgeRole::Maintain
2220 } else if has(|p| p.push) {
2221 ForgeRole::Write
2222 } else if has(|p| p.triage) {
2223 ForgeRole::Triage
2224 } else {
2225 ForgeRole::Read
2226 }
2227 }
2228}
2229
2230#[derive(Deserialize, Default)]
2231#[serde(default)]
2232struct PermsJson {
2233 admin: bool,
2234 maintain: bool,
2235 push: bool,
2236 triage: bool,
2237}
2238
2239#[derive(Deserialize)]
2240struct InvitationJson {
2241 id: u64,
2242 #[serde(default)]
2243 invitee: Option<UserJson>,
2244 permissions: String,
2245}
2246
2247#[derive(Deserialize)]
2248struct RulesetSummary {
2249 id: u64,
2250 name: String,
2251}
2252
2253#[derive(Deserialize)]
2254struct RulesetJson {
2255 id: u64,
2256 #[serde(default)]
2257 target: Option<String>,
2258 enforcement: String,
2259 #[serde(default)]
2262 bypass_actors: Option<Vec<BypassJson>>,
2263 #[serde(default)]
2264 current_user_can_bypass: Option<String>,
2265 #[serde(default)]
2266 conditions: Option<Value>,
2267 #[serde(default)]
2268 rules: Vec<RuleJson>,
2269}
2270
2271#[derive(Deserialize)]
2272struct BypassJson {
2273 #[serde(default)]
2274 actor_id: Option<u64>,
2275 actor_type: String,
2276 #[serde(default)]
2277 bypass_mode: Option<String>,
2278}
2279
2280#[derive(Deserialize)]
2281struct RuleJson {
2282 #[serde(rename = "type")]
2283 kind: String,
2284 #[serde(default)]
2285 parameters: Option<Value>,
2286}
2287
2288#[derive(Deserialize)]
2289struct InstallationJson {
2290 id: u64,
2291 account: AccountJson,
2292 #[serde(default)]
2293 permissions: BTreeMap<String, String>,
2294 #[serde(default)]
2295 events: Vec<String>,
2296 #[serde(default)]
2297 suspended_at: Option<String>,
2298}
2299
2300fn installation_missing(inst: &InstallationJson) -> Vec<String> {
2303 let mut missing = missing_permissions(&inst.permissions);
2304 missing.extend(
2305 crate::manifest::CHECK_EVENTS
2306 .iter()
2307 .filter(|e| !inst.events.iter().any(|x| x == *e))
2308 .map(|e| format!("event:{e}")),
2309 );
2310 missing
2311}
2312
2313#[derive(Deserialize)]
2314struct AccountJson {
2315 id: u64,
2316 login: String,
2317 #[serde(rename = "type")]
2318 kind: String,
2319}
2320
2321#[derive(Deserialize)]
2322struct ContentJson {
2323 sha: String,
2324 #[serde(rename = "type")]
2325 kind: String,
2326 #[serde(default)]
2327 content: Option<String>,
2328 #[serde(default)]
2329 encoding: Option<String>,
2330}
2331
2332#[derive(Deserialize)]
2333struct DeviceCodeJson {
2334 device_code: Option<String>,
2335 user_code: Option<String>,
2336 verification_uri: Option<String>,
2337 expires_in: Option<u64>,
2338 interval: Option<u64>,
2339 error: Option<String>,
2340 error_description: Option<String>,
2341}
2342
2343#[derive(Deserialize, Default)]
2344pub(crate) struct TokenPollJson {
2345 pub(crate) access_token: Option<String>,
2346 pub(crate) error: Option<String>,
2347 pub(crate) error_description: Option<String>,
2348 pub(crate) interval: Option<u64>,
2349}
2350
2351impl std::fmt::Debug for TokenPollJson {
2352 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2353 f.debug_struct("TokenPollJson")
2354 .field(
2355 "access_token",
2356 &self.access_token.as_ref().map(|_| "<redacted>"),
2357 )
2358 .field("error", &self.error)
2359 .field("interval", &self.interval)
2360 .finish()
2361 }
2362}
2363
2364#[cfg(test)]
2365mod tests {
2366 use super::*;
2367
2368 fn poll(error: &str, interval: Option<u64>) -> TokenPollJson {
2369 TokenPollJson {
2370 error: Some(error.into()),
2371 interval,
2372 ..TokenPollJson::default()
2373 }
2374 }
2375
2376 #[test]
2377 fn device_polling_backs_off_on_slow_down() {
2378 assert_eq!(
2379 next_poll(5, &poll("authorization_pending", None)).unwrap(),
2380 Some(5)
2381 );
2382 assert_eq!(next_poll(5, &poll("slow_down", None)).unwrap(), Some(10));
2383 assert_eq!(
2384 next_poll(5, &poll("slow_down", Some(15))).unwrap(),
2385 Some(15)
2386 );
2387 assert_eq!(next_poll(5, &poll("slow_down", Some(1))).unwrap(), Some(10));
2389 assert!(matches!(
2390 next_poll(5, &poll("expired_token", None)),
2391 Err(ForgeError::LinkFailed(_))
2392 ));
2393 assert!(matches!(
2394 next_poll(5, &poll("access_denied", None)),
2395 Err(ForgeError::LinkFailed(_))
2396 ));
2397 }
2398
2399 #[test]
2400 fn roles_map_both_ways() {
2401 for role in [
2402 ForgeRole::Read,
2403 ForgeRole::Triage,
2404 ForgeRole::Write,
2405 ForgeRole::Maintain,
2406 ForgeRole::Admin,
2407 ] {
2408 assert_eq!(role_from_name(invitation_permission(role)), Some(role));
2409 assert_eq!(role_from_name(put_permission(role)), Some(role));
2410 }
2411 let custom = CollaboratorJson {
2412 id: 1,
2413 login: "x".into(),
2414 role_name: Some("security-reviewer".into()),
2415 permissions: Some(PermsJson {
2416 triage: true,
2417 ..PermsJson::default()
2418 }),
2419 };
2420 assert_eq!(custom.role(), ForgeRole::Triage);
2421 }
2422}