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 {
520 status: 422,
521 ref message,
522 } if message.contains("permissions requested are not granted") => {
523 ForgeError::Forbidden(format!(
524 "the App installation on `{}` does not grant {}: {message}",
525 ns.resource,
526 perms
527 .iter()
528 .map(|(n, l)| format!("{n}:{l}"))
529 .collect::<Vec<_>>()
530 .join(", ")
531 ))
532 }
533 ForgeError::Rejected { status: 422, .. } if repo.is_some() => {
536 ForgeError::NotFound {
537 what: format!(
538 "{}/{} (not visible to the App installation)",
539 ns.resource,
540 repo.unwrap_or_default()
541 ),
542 }
543 }
544 e => e,
545 })?;
546 Ok(Secret::new(t.token))
547 }
548
549 fn namespace(&self, ns: &Resource) -> Result<Namespace> {
550 self.namespaces
551 .read()
552 .expect("namespace lock poisoned")
553 .get(ns)
554 .cloned()
555 .ok_or_else(|| ForgeError::NotBound {
556 namespace: ns.to_string(),
557 })
558 }
559
560 fn locate<'r>(&self, repo: &'r Resource) -> Result<(Namespace, &'r str, &'r str)> {
563 if repo.host() != self.config.host {
564 return Err(ForgeError::WrongResource {
565 resource: repo.to_string(),
566 expected: format!("a repository on `{}`", self.config.host),
567 });
568 }
569 repo.require_owner_repo()?;
573 let name = repo.repo_name().ok_or_else(|| ForgeError::WrongResource {
574 resource: repo.to_string(),
575 expected: "a repository (`<host>/<owner>/<repo>`), not a namespace".into(),
576 })?;
577 Ok((self.namespace(&repo.namespace())?, repo.owner(), name))
578 }
579
580 async fn repo_token(
581 &self,
582 repo: &Resource,
583 perms: &[(&str, &str)],
584 ) -> Result<(Secret, String, String)> {
585 let (ns, owner, name) = self.locate(repo)?;
586 let token = self.installation_token(&ns, Some(name), perms).await?;
587 Ok((token, owner.to_string(), name.to_string()))
588 }
589
590 async fn actions_app_id(&self, token: &Secret) -> Result<u64> {
592 if let Some(id) = *self.actions_app_id.lock().expect("lock poisoned") {
593 return Ok(id);
594 }
595 #[derive(Deserialize)]
596 struct App {
597 id: u64,
598 }
599 let app: App = self
600 .api
601 .json(
602 Method::GET,
603 self.api.url(&["apps", "github-actions"]),
604 Auth::Bearer(token),
605 None,
606 "GitHub Actions app",
607 )
608 .await?;
609 *self.actions_app_id.lock().expect("lock poisoned") = Some(app.id);
610 Ok(app.id)
611 }
612
613 async fn revoke_user_token(&self, token: &Secret) {
618 let Some(secret) = &self.client_secret else {
619 return;
620 };
621 let url = self
622 .api
623 .url(&["applications", &self.config.client_id, "token"]);
624 let body = json!({ "access_token": token.expose() });
625 if let Err(e) = self
626 .api
627 .basic_delete(url, &self.config.client_id, secret, &body)
628 .await
629 {
630 tracing::warn!(error = %e, "could not revoke a member's user token after linking");
631 }
632 }
633
634 pub async fn repository_by_id(&self, ns: &Resource, id: u64) -> Result<Option<Resource>> {
641 let namespace = self.namespace(ns)?;
642 let token = self
643 .installation_token(&namespace, None, PERMS_METADATA)
644 .await?;
645 let url = self.api.url(&["repositories", &id.to_string()]);
646 let r: Option<RepoJson> = self
647 .api
648 .get_opt(url, Auth::Bearer(&token), "repository")
649 .await?;
650 match r {
651 Some(r) if r.id == id => Ok(Some(Resource::parse_owner_repo(&format!(
652 "{}/{}",
653 self.config.host, r.full_name
654 ))?)),
655 _ => Ok(None),
656 }
657 }
658
659 fn repo_state(&self, r: &RepoJson) -> Result<RepoState> {
660 let resource =
661 Resource::parse_owner_repo(&format!("{}/{}", self.config.host, r.full_name))?;
662 let mut state = RepoState::new(resource, r.id);
663 state.visibility = match r.visibility.as_deref() {
664 Some("public") => Visibility::Public,
665 Some("internal") => Visibility::Internal,
666 Some("private") => Visibility::Private,
667 _ if r.private => Visibility::Private,
668 _ => Visibility::Public,
669 };
670 state.archived = r.archived;
671 state.default_branch = r.default_branch.clone();
672 Ok(state)
673 }
674
675 async fn collaborators(
676 &self,
677 token: &Secret,
678 owner: &str,
679 name: &str,
680 ) -> Result<Vec<CollaboratorJson>> {
681 let mut url = self.api.url(&["repos", owner, name, "collaborators"]);
682 url.query_pairs_mut().append_pair("affiliation", "direct");
683 self.api
684 .get_all(url, Auth::Bearer(token), "collaborators")
685 .await
686 }
687
688 async fn invitations(
689 &self,
690 token: &Secret,
691 owner: &str,
692 name: &str,
693 ) -> Result<Vec<InvitationJson>> {
694 let url = self.api.url(&["repos", owner, name, "invitations"]);
695 self.api
696 .get_all(url, Auth::Bearer(token), "invitations")
697 .await
698 }
699
700 async fn managed_ruleset(
701 &self,
702 token: &Secret,
703 owner: &str,
704 name: &str,
705 ) -> Result<Option<RulesetJson>> {
706 let mut url = self.api.url(&["repos", owner, name, "rulesets"]);
707 url.query_pairs_mut()
708 .append_pair("includes_parents", "false");
709 let list: Vec<RulesetSummary> = self
710 .api
711 .get_all(url, Auth::Bearer(token), "rulesets")
712 .await?;
713 let Some(summary) = list.into_iter().find(|r| r.name == RULESET_NAME) else {
714 return Ok(None);
715 };
716 let url = self
717 .api
718 .url(&["repos", owner, name, "rulesets", &summary.id.to_string()]);
719 self.api.get_opt(url, Auth::Bearer(token), "ruleset").await
720 }
721
722 fn protection(
723 &self,
724 rs: &RulesetJson,
725 default_branch: Option<&str>,
726 actions_id: Option<u64>,
727 ) -> ProtectionState {
728 protection_of(rs, default_branch, actions_id)
729 }
730
731 async fn write_file(
734 &self,
735 repo: &Resource,
736 path: &str,
737 contents: &[u8],
738 message: &str,
739 ) -> Result<StepOutcome> {
740 validate_repo_path(path)?;
741 let (token, owner, name) = self.repo_token(repo, PERMS_CONTENTS).await?;
742 self.write_file_with(&token, &owner, &name, path, contents, message)
743 .await
744 }
745
746 async fn write_file_with(
748 &self,
749 token: &Secret,
750 owner: &str,
751 name: &str,
752 path: &str,
753 contents: &[u8],
754 message: &str,
755 ) -> Result<StepOutcome> {
756 validate_repo_path(path)?;
757 let mut segments = vec!["repos", owner, name, "contents"];
758 segments.extend(path.split('/'));
759 let url = self.api.url(&segments);
760
761 let existing: Option<Value> = self
764 .api
765 .get_opt(url.clone(), Auth::Bearer(token), path)
766 .await?;
767 let existing = match existing {
768 Some(Value::Array(_)) => {
769 return Err(ForgeError::Rejected {
770 status: 409,
771 message: format!("`{path}` exists and is a directory, not a file"),
772 });
773 }
774 Some(v) => Some(
775 serde_json::from_value::<ContentJson>(v)
776 .map_err(|e| ForgeError::Protocol(format!("{path}: {e}")))?,
777 ),
778 None => None,
779 };
780 let sha = match existing {
781 Some(c) if c.kind != "file" => {
782 return Err(ForgeError::Rejected {
783 status: 409,
784 message: format!("`{path}` exists and is a {}, not a file", c.kind),
785 });
786 }
787 Some(c) => {
788 if decode_content(&c)? == contents {
789 return Ok(StepOutcome::Unchanged);
790 }
791 Some(c.sha)
792 }
793 None => None,
794 };
795
796 let mut body = json!({ "message": message, "content": STANDARD.encode(contents) });
797 if let Some(sha) = &sha {
798 body["sha"] = json!(sha);
799 }
800 self.api
801 .send(Method::PUT, url, Auth::Bearer(token), Some(&body), path)
802 .await
803 .map_err(|e| match e {
804 ForgeError::Rejected { status, message } => ForgeError::Rejected {
805 status,
806 message: format!(
807 "{message} — if the default branch is already protected, this file can \
808 only change through a pull request (the ruleset has no bypass actors, \
809 by design)"
810 ),
811 },
812 e => e,
813 })?;
814 Ok(if sha.is_some() {
815 StepOutcome::Updated
816 } else {
817 StepOutcome::Created
818 })
819 }
820
821 async fn set_variable(&self, repo: &Resource, var: &str, value: &str) -> Result<StepOutcome> {
822 check_variable_name(var)?;
823 let (token, owner, name) = self.repo_token(repo, PERMS_VARIABLES).await?;
824 let url = self
825 .api
826 .url(&["repos", &owner, &name, "actions", "variables", var]);
827 #[derive(Deserialize)]
828 struct Variable {
829 value: String,
830 }
831 let body = json!({ "name": var, "value": value });
832 match self
833 .api
834 .get_opt::<Variable>(url.clone(), Auth::Bearer(&token), var)
835 .await?
836 {
837 Some(v) if v.value == value => Ok(StepOutcome::Unchanged),
838 Some(_) => {
839 self.api
840 .send(Method::PATCH, url, Auth::Bearer(&token), Some(&body), var)
841 .await?;
842 Ok(StepOutcome::Updated)
843 }
844 None => {
845 let url = self
846 .api
847 .url(&["repos", &owner, &name, "actions", "variables"]);
848 self.api
849 .send(Method::POST, url, Auth::Bearer(&token), Some(&body), var)
850 .await?;
851 Ok(StepOutcome::Created)
852 }
853 }
854 }
855
856 async fn protect(&self, repo: &Resource, spec: &ProtectionSpec) -> Result<StepOutcome> {
857 let (ns, _, _) = self.locate(repo)?;
858 let (token, owner, name) = self.repo_token(repo, PERMS_ADMIN).await?;
859 let bridge_posted = self.capabilities(&ns).bridge_posted_check;
860 self.protect_with(&token, &owner, &name, spec, bridge_posted)
861 .await
862 }
863
864 async fn check_integration_id(&self, token: &Secret, bridge_posted: bool) -> Result<u64> {
867 if bridge_posted {
868 Ok(self.config.app_id)
869 } else {
870 self.actions_app_id(token).await
871 }
872 }
873
874 async fn protect_with(
878 &self,
879 token: &Secret,
880 owner: &str,
881 name: &str,
882 spec: &ProtectionSpec,
883 bridge_posted: bool,
884 ) -> Result<StepOutcome> {
885 let actions_id = if spec.require_status_check {
888 Some(self.check_integration_id(token, bridge_posted).await?)
889 } else {
890 None
891 };
892 let repo_json: RepoJson = self
893 .api
894 .json(
895 Method::GET,
896 self.api.url(&["repos", owner, name]),
897 Auth::Bearer(token),
898 None,
899 name,
900 )
901 .await?;
902 let body = ruleset_body(spec, actions_id);
903
904 match self.managed_ruleset(token, owner, name).await? {
905 Some(rs) => {
906 let observed =
907 self.protection(&rs, repo_json.default_branch.as_deref(), actions_id);
908 if satisfies(&observed, spec) && rules_match(&rs, spec) {
909 return Ok(StepOutcome::Unchanged);
910 }
911 let url = self
912 .api
913 .url(&["repos", owner, name, "rulesets", &rs.id.to_string()]);
914 self.api
915 .send(
916 Method::PUT,
917 url,
918 Auth::Bearer(token),
919 Some(&body),
920 "ruleset",
921 )
922 .await?;
923 Ok(StepOutcome::Updated)
924 }
925 None => {
926 let url = self.api.url(&["repos", owner, name, "rulesets"]);
927 self.api
928 .send(
929 Method::POST,
930 url,
931 Auth::Bearer(token),
932 Some(&body),
933 "ruleset",
934 )
935 .await?;
936 Ok(StepOutcome::Created)
937 }
938 }
939 }
940
941 async fn remove_file(&self, repo: &Resource, path: &str, message: &str) -> Result<StepOutcome> {
943 validate_repo_path(path)?;
944 let (token, owner, name) = self.repo_token(repo, PERMS_CONTENTS).await?;
945 let Some((_, sha)) = self.file_at(&token, &owner, &name, path, None).await? else {
946 return Ok(StepOutcome::Unchanged);
947 };
948 let mut segments = vec!["repos", owner.as_str(), name.as_str(), "contents"];
949 segments.extend(path.split('/'));
950 let body = json!({ "message": message, "sha": sha });
951 self.api
952 .send(
953 Method::DELETE,
954 self.api.url(&segments),
955 Auth::Bearer(&token),
956 Some(&body),
957 path,
958 )
959 .await
960 .map_err(|e| match e {
961 ForgeError::Rejected { status, message } => ForgeError::Rejected {
962 status,
963 message: format!(
964 "{message} — the default branch is protected, so this clean-up has to \
965 land through a pull request"
966 ),
967 },
968 e => e,
969 })?;
970 Ok(StepOutcome::Updated)
971 }
972
973 async fn remove_variable(&self, repo: &Resource, var: &str) -> Result<StepOutcome> {
975 check_variable_name(var)?;
976 let (token, owner, name) = self.repo_token(repo, PERMS_VARIABLES).await?;
977 let url = self
978 .api
979 .url(&["repos", &owner, &name, "actions", "variables", var]);
980 if self
981 .api
982 .get_opt::<Value>(url.clone(), Auth::Bearer(&token), var)
983 .await?
984 .is_none()
985 {
986 return Ok(StepOutcome::Unchanged);
987 }
988 self.api
989 .send(Method::DELETE, url, Auth::Bearer(&token), None, var)
990 .await?;
991 Ok(StepOutcome::Updated)
992 }
993
994 fn is_personal_owner(ns: &Namespace, id: u64) -> bool {
1000 ns.kind == NamespaceKind::User && Some(id) == ns.owner_id
1001 }
1002
1003 fn expressible(&self, ns: &Namespace, desired: &[RoleAssignment]) -> Vec<RoleAssignment> {
1006 desired
1007 .iter()
1008 .filter(|a| !Self::is_personal_owner(ns, a.account.id))
1009 .cloned()
1010 .collect()
1011 }
1012
1013 async fn login_for(&self, token: &Secret, id: u64) -> Result<String> {
1014 let user: UserJson = self
1017 .api
1018 .json(
1019 Method::GET,
1020 self.api.url(&["user", &id.to_string()]),
1021 Auth::Bearer(token),
1022 None,
1023 "user",
1024 )
1025 .await?;
1026 if user.id != id {
1027 return Err(ForgeError::Protocol(format!(
1028 "asked for user {id}, GitHub answered with {}",
1029 user.id
1030 )));
1031 }
1032 Ok(user.login)
1033 }
1034
1035 #[allow(clippy::too_many_arguments)]
1036 async fn change_role(
1037 &self,
1038 token: &Secret,
1039 ns: &Namespace,
1040 owner: &str,
1041 name: &str,
1042 account: &ForgeAccount,
1043 to: ForgeRole,
1044 current: Option<&Current>,
1045 ) -> Result<RoleOutcome> {
1046 let auth = Auth::Bearer(token);
1047 match (current, to) {
1048 (Some(Current::Invited { id, .. }), ForgeRole::None) => {
1049 let url = self
1050 .api
1051 .url(&["repos", owner, name, "invitations", &id.to_string()]);
1052 self.api
1053 .send(Method::DELETE, url, auth, None, "invitation")
1054 .await?;
1055 Ok(RoleOutcome::Applied)
1056 }
1057 (Some(Current::Member { login, .. }), ForgeRole::None) => {
1058 let url = self
1059 .api
1060 .url(&["repos", owner, name, "collaborators", login]);
1061 self.api
1062 .send(Method::DELETE, url, auth, None, "collaborator")
1063 .await?;
1064 Ok(RoleOutcome::Applied)
1065 }
1066 (None, ForgeRole::None) => Ok(RoleOutcome::Applied),
1067 (Some(Current::Invited { id, .. }), role) => {
1068 let url = self
1069 .api
1070 .url(&["repos", owner, name, "invitations", &id.to_string()]);
1071 let body = json!({ "permissions": invitation_permission(role) });
1072 self.api
1073 .send(Method::PATCH, url, auth, Some(&body), "invitation")
1074 .await?;
1075 Ok(RoleOutcome::Invited)
1076 }
1077 (_, role) => {
1078 let login = self.login_for(token, account.id).await?;
1079 let url = self
1080 .api
1081 .url(&["repos", owner, name, "collaborators", &login]);
1082 let body = (ns.kind == NamespaceKind::Organization)
1085 .then(|| json!({ "permission": put_permission(role) }));
1086 let resp = self
1087 .api
1088 .send(Method::PUT, url, auth, body.as_ref(), "collaborator")
1089 .await?;
1090 Ok(if resp.status() == reqwest::StatusCode::CREATED {
1091 RoleOutcome::Invited
1092 } else {
1093 RoleOutcome::Applied
1094 })
1095 }
1096 }
1097 }
1098
1099 async fn access_sources(
1106 &self,
1107 token: &Secret,
1108 owner: &str,
1109 name: &str,
1110 login: &str,
1111 ) -> Vec<AccessSource> {
1112 let auth = Auth::Bearer(token);
1113 let membership = |url| async move {
1114 self.api
1115 .get_opt::<MembershipJson>(url, auth, "membership")
1116 .await
1117 .ok()
1118 .flatten()
1119 .filter(|m| m.state == "active")
1120 };
1121 let mut via = Vec::new();
1122 let org = membership(self.api.url(&["orgs", owner, "memberships", login])).await;
1123 if org.as_ref().is_some_and(|m| m.role == "admin") {
1124 via.push(AccessSource::OrgOwner(owner.to_string()));
1125 }
1126 let teams: Vec<TeamJson> = self
1127 .api
1128 .get_all(
1129 self.api.url(&["repos", owner, name, "teams"]),
1130 auth,
1131 "repository teams",
1132 )
1133 .await
1134 .unwrap_or_default();
1135 for t in teams {
1136 let url = self
1137 .api
1138 .url(&["orgs", owner, "teams", &t.slug, "memberships", login]);
1139 if membership(url).await.is_some() {
1140 via.push(AccessSource::Team(t.name));
1141 }
1142 }
1143 if via.is_empty() && org.is_some() {
1144 via.push(AccessSource::OrgMember(owner.to_string()));
1145 }
1146 via
1147 }
1148}
1149
1150enum Current {
1152 Member { login: String, role: ForgeRole },
1153 Invited { id: u64, role: ForgeRole },
1154}
1155
1156impl Current {
1157 fn role(&self) -> ForgeRole {
1158 match self {
1159 Current::Member { role, .. } | Current::Invited { role, .. } => *role,
1160 }
1161 }
1162}
1163
1164#[async_trait]
1165impl Forge for GitHubForge {
1166 fn kind(&self) -> ForgeKind {
1167 ForgeKind::GitHub
1168 }
1169
1170 fn host(&self) -> &str {
1171 &self.config.host
1172 }
1173
1174 fn capabilities(&self, ns: &Namespace) -> Capabilities {
1175 let automated = ns.installation_id.is_some();
1176 let mut c = Capabilities::default();
1177 c.automation = automated;
1178 c.required_checks = RequiredCheckKind::Ruleset;
1179 c.account_link = LinkMethod::DeviceFlow;
1180 c.webhooks = automated;
1181 c.per_repo_tokens = automated;
1182 c.required_workflow = automated
1183 && ns.kind == NamespaceKind::Organization
1184 && self.required_workflow_known(&ns.resource);
1185 c.bridge_posted_check = automated
1189 && !c.required_workflow
1190 && self.config.bridge_checks
1191 && self.bridge_checks_ready(&ns.resource) == Some(true);
1192 c.single_owner_repos_unreviewed = !c.required_workflow && !c.bridge_posted_check;
1196 match ns.kind {
1197 NamespaceKind::User => {
1198 c.bot_can_create_repos = false;
1201 c.role_levels = USER_LADDER.to_vec();
1202 }
1203 _ => {
1204 c.bot_can_create_repos = automated;
1205 c.role_levels = ORG_LADDER.to_vec();
1206 }
1207 }
1208 c
1209 }
1210
1211 async fn begin_bind(&self, req: BindRequest) -> Result<BindStep> {
1212 if req.namespace.host() != self.config.host || !req.namespace.is_namespace() {
1213 return Err(ForgeError::WrongResource {
1214 resource: req.namespace.to_string(),
1215 expected: format!("a namespace on `{}`", self.config.host),
1216 });
1217 }
1218 if req.state.len() < MIN_STATE_LEN
1219 || !req
1220 .state
1221 .bytes()
1222 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1223 {
1224 return Err(ForgeError::Config(format!(
1225 "bind state must be at least {MIN_STATE_LEN} base64url characters from a CSPRNG \
1226 (see GitHubForge::new_state)"
1227 )));
1228 }
1229 let mut url = self
1230 .api
1231 .web_url(&["apps", &self.config.app_slug, "installations", "new"]);
1232 url.query_pairs_mut().append_pair("state", &req.state);
1233 Ok(BindStep::Redirect {
1234 url: url.to_string(),
1235 })
1236 }
1237
1238 async fn complete_bind(&self, cb: BindCallback) -> Result<NamespaceBinding> {
1239 let reject = |m: String| Err(ForgeError::BindRejected(m));
1240 let state = cb.params.get("state").map(String::as_str).unwrap_or("");
1241 if cb.expected_state.len() < MIN_STATE_LEN
1242 || aws_lc_rs::constant_time::verify_slices_are_equal(
1243 state.as_bytes(),
1244 cb.expected_state.as_bytes(),
1245 )
1246 .is_err()
1247 {
1248 return reject("the `state` does not match a bind this VTC started".into());
1249 }
1250 match cb.params.get("setup_action").map(String::as_str) {
1251 Some("install") | Some("update") | None => {}
1252 Some("request") => {
1253 return reject(
1254 "the installation was requested but an owner has not approved it yet".into(),
1255 );
1256 }
1257 Some(other) => return reject(format!("unexpected setup_action `{other}`")),
1258 }
1259 if cb.expected_namespace.host() != self.config.host || !cb.expected_namespace.is_namespace()
1260 {
1261 return reject(format!(
1262 "`{}` is not a namespace on `{}`",
1263 cb.expected_namespace, self.config.host
1264 ));
1265 }
1266 let installation_id: u64 = match cb.params.get("installation_id").map(|s| s.parse()) {
1267 Some(Ok(id)) => id,
1268 _ => return reject("missing or malformed `installation_id`".into()),
1269 };
1270
1271 let jwt = self.jwt().await?;
1274 let url = self
1275 .api
1276 .url(&["app", "installations", &installation_id.to_string()]);
1277 let inst: InstallationJson = match self
1278 .api
1279 .json(Method::GET, url, Auth::Bearer(&jwt), None, "installation")
1280 .await
1281 {
1282 Ok(i) => i,
1283 Err(ForgeError::NotFound { .. }) => {
1284 return reject(format!(
1285 "installation {installation_id} is not an installation of this App"
1286 ));
1287 }
1288 Err(e) => return Err(e),
1289 };
1290 if inst.id != installation_id {
1291 return reject("GitHub returned a different installation".into());
1292 }
1293 if !inst
1294 .account
1295 .login
1296 .eq_ignore_ascii_case(cb.expected_namespace.owner())
1297 {
1298 return reject(format!(
1299 "the App was installed on `{}`, but the bind was for `{}`",
1300 inst.account.login, cb.expected_namespace
1301 ));
1302 }
1303 if inst.suspended_at.is_some() {
1304 return reject("the installation is suspended".into());
1305 }
1306 let kind = match inst.account.kind.as_str() {
1307 "Organization" => NamespaceKind::Organization,
1308 "User" => NamespaceKind::User,
1309 other => return reject(format!("unsupported account type `{other}`")),
1310 };
1311 let namespace = Namespace::new(cb.expected_namespace.clone(), kind)
1312 .with_owner_id(inst.account.id)
1313 .with_installation(installation_id);
1314 let probed = match self.probe_org_rulesets(&namespace).await {
1318 Ok(available) => {
1319 self.set_required_workflow(&namespace.resource, available);
1320 true
1321 }
1322 Err(e) => {
1323 tracing::warn!(error = %e, "could not tell whether org rulesets are available");
1324 false
1325 }
1326 };
1327 self.set_bridge_checks_ready(
1331 &namespace.resource,
1332 crate::manifest::check_ready(&inst.permissions, &inst.events),
1333 );
1334 let binding = NamespaceBinding::new(namespace, installation_missing(&inst));
1335 Ok(if probed {
1338 let caps = self.capabilities(&binding.namespace);
1339 binding.with_capabilities(caps)
1340 } else {
1341 binding
1342 })
1343 }
1344
1345 async fn begin_account_link(&self, member: &str) -> Result<LinkStep> {
1346 tracing::debug!(member, "starting GitHub device flow");
1347 let url = self.api.web_url(&["login", "device", "code"]);
1348 let resp: DeviceCodeJson = self
1349 .api
1350 .oauth(url, &json!({ "client_id": self.config.client_id }))
1351 .await?;
1352 if let Some(err) = resp.error {
1353 return Err(ForgeError::LinkFailed(format!(
1354 "{err}: {}",
1355 resp.error_description.unwrap_or_default()
1356 )));
1357 }
1358 let missing = || ForgeError::Protocol("device code response is incomplete".into());
1359 Ok(LinkStep::DeviceCode {
1360 device_code: resp.device_code.ok_or_else(missing)?,
1361 user_code: resp.user_code.ok_or_else(missing)?,
1362 verification_uri: resp.verification_uri.ok_or_else(missing)?,
1363 expires_in: resp.expires_in.ok_or_else(missing)?,
1364 interval: resp.interval.unwrap_or(5),
1365 })
1366 }
1367
1368 async fn complete_account_link(&self, cb: LinkCallback) -> Result<ForgeAccount> {
1369 let LinkCallback::DeviceCode {
1370 device_code,
1371 mut interval,
1372 expires_in,
1373 } = cb
1374 else {
1375 return Err(ForgeError::Unsupported {
1376 operation: "redirect account link".into(),
1377 hint: "GitHub links accounts through the device flow".into(),
1378 });
1379 };
1380 let url = self.api.web_url(&["login", "oauth", "access_token"]);
1381 let body = json!({
1382 "client_id": self.config.client_id,
1383 "device_code": device_code,
1384 "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
1385 });
1386 let expires_in = expires_in.min(DEVICE_CODE_MAX_LIFETIME_SECS);
1389 let mut waited = 0u64;
1390 let token = loop {
1391 if waited >= expires_in {
1392 return Err(ForgeError::LinkFailed(
1393 "the device code expired before the member approved; start again".into(),
1394 ));
1395 }
1396 tokio::time::sleep(self.config.device_poll_unit * interval.max(1) as u32).await;
1397 waited += interval.max(1);
1398 let poll: TokenPollJson = self.api.oauth(url.clone(), &body).await?;
1399 if let Some(token) = poll.access_token {
1400 break Secret::new(token);
1401 }
1402 match next_poll(interval, &poll)? {
1403 Some(next) => interval = next,
1404 None => unreachable!("next_poll returns Some or Err when there is no token"),
1405 }
1406 };
1407
1408 let user: UserJson = self
1409 .api
1410 .json(
1411 Method::GET,
1412 self.api.url(&["user"]),
1413 Auth::Bearer(&token),
1414 None,
1415 "authenticated user",
1416 )
1417 .await?;
1418 self.revoke_user_token(&token).await;
1422 Ok(ForgeAccount::new(user.id, user.login))
1423 }
1424
1425 async fn inspect(&self, repo: &Resource) -> Result<RepoState> {
1426 let (ns, _, _) = self.locate(repo)?;
1427 let (token, owner, name) = self.repo_token(repo, PERMS_ADMIN).await?;
1428 let auth = Auth::Bearer(&token);
1429 let r: RepoJson = self
1430 .api
1431 .json(
1432 Method::GET,
1433 self.api.url(&["repos", &owner, &name]),
1434 auth,
1435 None,
1436 repo.as_str(),
1437 )
1438 .await?;
1439 let mut state = self.repo_state(&r)?;
1440
1441 for c in self.collaborators(&token, &owner, &name).await? {
1442 if Self::is_personal_owner(&ns, c.id) {
1443 continue;
1444 }
1445 let role = c.role();
1446 state
1447 .collaborators
1448 .push(Collaborator::new(ForgeAccount::new(c.id, c.login), role));
1449 }
1450 for i in self.invitations(&token, &owner, &name).await? {
1451 if let Some(user) = i.invitee {
1452 state.collaborators.push(Collaborator::invited(
1453 ForgeAccount::new(user.id, user.login),
1454 role_from_name(&i.permissions).unwrap_or(ForgeRole::Read),
1455 ));
1456 }
1457 }
1458 let caps = self.capabilities(&ns);
1459 let rs = self.managed_ruleset(&token, &owner, &name).await?;
1460 if let Some(rs) = &rs {
1461 let check_app = self
1463 .check_integration_id(&token, caps.bridge_posted_check)
1464 .await?;
1465 state.protection = self.protection(rs, r.default_branch.as_deref(), Some(check_app));
1466 }
1467 if !caps.bridge_posted_check {
1468 self.inspect_actions_policy(&token, &owner, &name, &mut state.protection)
1470 .await?;
1471 }
1472 drop(token);
1473 if caps.bridge_posted_check {
1474 state.protection.check_source_guard = vgi_forge::CheckSourceGuard::BridgePosted;
1477 } else if caps.required_workflow {
1478 self.inspect_required_workflow(&ns, r.id, &mut state.protection)
1479 .await?;
1480 } else if caps.automation {
1481 self.inspect_owner_review(
1482 repo,
1483 &owner,
1484 &name,
1485 rs.as_ref(),
1486 r.default_branch.as_deref(),
1487 &mut state.protection,
1488 )
1489 .await?;
1490 }
1491 Ok(state)
1492 }
1493
1494 async fn create_repo(&self, spec: &RepoSpec) -> Result<RepoState> {
1495 let (ns, owner, name) = self.locate(&spec.resource)?;
1496 if !self.capabilities(&ns).bot_can_create_repos {
1497 return Err(ForgeError::Unsupported {
1498 operation: "repository creation".into(),
1499 hint: format!(
1500 "the bridge cannot create repositories in `{}`; the account holder runs \
1501 `gh repo create {owner}/{name}` and `vgi repo init`, then the repo is adopted",
1502 ns.resource
1503 ),
1504 });
1505 }
1506 let token = self.installation_token(&ns, None, PERMS_ADMIN).await?;
1513 let auth = Auth::Bearer(&token);
1514 if let Some(existing) = self
1515 .api
1516 .get_opt::<RepoJson>(
1517 self.api.url(&["repos", owner, name]),
1518 auth,
1519 spec.resource.as_str(),
1520 )
1521 .await?
1522 {
1523 return Err(ForgeError::AlreadyExists {
1524 resource: spec.resource.to_string(),
1525 forge_id: Some(existing.id),
1526 });
1527 }
1528 let mut body = json!({
1529 "name": name,
1530 "visibility": match spec.visibility {
1531 Visibility::Private => "private",
1532 Visibility::Internal => "internal",
1533 _ => "public",
1534 },
1535 "auto_init": true,
1538 });
1539 if let Some(d) = &spec.description {
1540 body["description"] = json!(d);
1541 }
1542 let created: RepoJson = self
1543 .api
1544 .json(
1545 Method::POST,
1546 self.api.url(&["orgs", owner, "repos"]),
1547 auth,
1548 Some(&body),
1549 spec.resource.as_str(),
1550 )
1551 .await
1552 .map_err(|e| match e {
1553 ForgeError::Rejected {
1554 status: 422,
1555 message,
1556 } if message.contains("already exists") => ForgeError::AlreadyExists {
1557 resource: spec.resource.to_string(),
1558 forge_id: None,
1559 },
1560 e => e,
1561 })?;
1562 self.repo_state(&created)
1563 }
1564
1565 async fn archive_repo(&self, repo: &Resource) -> Result<()> {
1566 let (token, owner, name) = self.repo_token(repo, PERMS_ADMIN).await?;
1567 let url = self.api.url(&["repos", &owner, &name]);
1568 let r: RepoJson = self
1569 .api
1570 .json(
1571 Method::GET,
1572 url.clone(),
1573 Auth::Bearer(&token),
1574 None,
1575 repo.as_str(),
1576 )
1577 .await?;
1578 if let Some(set) = self
1579 .managed
1580 .write()
1581 .expect("lock poisoned")
1582 .get_mut(&repo.namespace())
1583 {
1584 set.remove(&r.id);
1587 }
1588 if r.archived {
1589 return Ok(());
1590 }
1591 self.api
1592 .send(
1593 Method::PATCH,
1594 url,
1595 Auth::Bearer(&token),
1596 Some(&json!({ "archived": true })),
1597 repo.as_str(),
1598 )
1599 .await?;
1600 Ok(())
1601 }
1602
1603 async fn apply_roles(
1604 &self,
1605 repo: &Resource,
1606 desired: &[RoleAssignment],
1607 unlisted: Unlisted,
1608 ) -> Result<ApplyReport> {
1609 let (ns, owner, name) = self.locate(repo)?;
1610 let ladder = self.capabilities(&ns).role_levels;
1611 let desired = self.expressible(&ns, desired);
1612 let mut wanted: BTreeMap<u64, (ForgeAccount, ForgeRole)> = BTreeMap::new();
1613 for a in &desired {
1614 let role = collapse_to_ladder(a.role, &ladder);
1617 if let Some((_, prev)) = wanted.insert(a.account.id, (a.account.clone(), role))
1618 && prev != role
1619 {
1620 return Err(ForgeError::Config(format!(
1621 "account {} is assigned two different roles",
1622 a.account.id
1623 )));
1624 }
1625 }
1626
1627 let token = self
1628 .installation_token(&ns, Some(name), PERMS_ADMIN)
1629 .await?;
1630 let mut current: BTreeMap<u64, (ForgeAccount, Current)> = BTreeMap::new();
1631 for c in self.collaborators(&token, owner, name).await? {
1632 if Self::is_personal_owner(&ns, c.id) {
1633 continue;
1634 }
1635 let role = c.role();
1636 current.insert(
1637 c.id,
1638 (
1639 ForgeAccount::new(c.id, c.login.clone()),
1640 Current::Member {
1641 login: c.login,
1642 role,
1643 },
1644 ),
1645 );
1646 }
1647 for i in self.invitations(&token, owner, name).await? {
1648 if let Some(user) = i.invitee {
1649 let role = role_from_name(&i.permissions).unwrap_or(ForgeRole::Read);
1650 current.entry(user.id).or_insert((
1651 ForgeAccount::new(user.id, user.login),
1652 Current::Invited { id: i.id, role },
1653 ));
1654 }
1655 }
1656
1657 let mut report = ApplyReport::default();
1658 let mut todo: Vec<(ForgeAccount, ForgeRole)> = Vec::new();
1659 for (id, (account, role)) in &wanted {
1660 let have = current.get(id).map_or(ForgeRole::None, |(_, c)| c.role());
1661 if have == *role {
1662 if *role != ForgeRole::None {
1663 report.unchanged.push(account.clone());
1664 }
1665 } else {
1666 todo.push((account.clone(), *role));
1667 }
1668 }
1669 for (id, (account, c)) in ¤t {
1670 if wanted.contains_key(id) {
1671 continue;
1672 }
1673 match unlisted {
1674 Unlisted::Remove => todo.push((account.clone(), ForgeRole::None)),
1675 _ => {
1676 let mut collab = Collaborator::new(account.clone(), c.role());
1677 collab.pending = matches!(c, Current::Invited { .. });
1678 report.kept_unlisted.push(collab);
1679 }
1680 }
1681 }
1682
1683 let own_bot = format!("{}[bot]", self.config.app_slug);
1686 for (account, to) in todo {
1687 let cur = current.get(&account.id).map(|(_, c)| c);
1688 let from = cur.map_or(ForgeRole::None, Current::role);
1689 if to == ForgeRole::None
1690 && let Some(Current::Member { login, .. }) = cur
1691 && login.eq_ignore_ascii_case(&own_bot)
1692 {
1693 report.changes.push(RoleChange::new(
1694 account,
1695 from,
1696 to,
1697 RoleOutcome::Failed("the bridge's own App is never removed".into()),
1698 ));
1699 continue;
1700 }
1701 let outcome = match self
1702 .change_role(&token, &ns, owner, name, &account, to, cur)
1703 .await
1704 {
1705 Ok(o) => o,
1706 Err(e @ (ForgeError::Unauthorized(_) | ForgeError::RateLimited { .. })) => {
1709 return Err(e);
1710 }
1711 Err(e) => RoleOutcome::Failed(e.to_string()),
1712 };
1713 report
1714 .changes
1715 .push(RoleChange::new(account, from, to, outcome));
1716 }
1717 Ok(report)
1718 }
1719
1720 async fn indirect_access(
1724 &self,
1725 repo: &Resource,
1726 account: &ForgeAccount,
1727 ) -> Result<Option<IndirectAccess>> {
1728 let (ns, owner, name) = self.locate(repo)?;
1729 let org = ns.kind == NamespaceKind::Organization;
1730 let perms = if org { PERMS_ACCESS_ORG } else { PERMS_ACCESS };
1731 let token = self.installation_token(&ns, Some(name), perms).await?;
1732 let login = match self.login_for(&token, account.id).await {
1733 Ok(l) => l,
1734 Err(ForgeError::NotFound { .. }) => return Ok(None),
1736 Err(e) => return Err(e),
1737 };
1738 let url = self
1739 .api
1740 .url(&["repos", owner, name, "collaborators", &login, "permission"]);
1741 let Some(p) = self
1742 .api
1743 .get_opt::<PermissionJson>(url, Auth::Bearer(&token), "collaborator permission")
1744 .await?
1745 else {
1746 return Ok(None);
1747 };
1748 let role = p
1749 .role_name
1750 .as_deref()
1751 .and_then(role_from_name)
1752 .or_else(|| role_from_name(&p.permission))
1753 .unwrap_or(ForgeRole::None);
1754 if role == ForgeRole::None {
1755 return Ok(None);
1756 }
1757 if role == ForgeRole::Read {
1758 let r: RepoJson = self
1761 .api
1762 .json(
1763 Method::GET,
1764 self.api.url(&["repos", owner, name]),
1765 Auth::Bearer(&token),
1766 None,
1767 repo.as_str(),
1768 )
1769 .await?;
1770 if self.repo_state(&r)?.visibility != Visibility::Private {
1771 return Ok(None);
1772 }
1773 }
1774 let via = if org {
1775 self.access_sources(&token, owner, name, &login).await
1776 } else {
1777 Vec::new()
1778 };
1779 Ok(Some(IndirectAccess::new(role, via)))
1780 }
1781
1782 fn bootstrap_plan(&self, repo: &RepoSpec, cfg: &VgiConfig) -> Result<Vec<BootstrapStep>> {
1783 if repo.resource.host() != self.config.host {
1784 return Err(ForgeError::WrongResource {
1785 resource: repo.resource.to_string(),
1786 expected: format!("a repository on `{}`", self.config.host),
1787 });
1788 }
1789 repo.resource.require_owner_repo()?;
1790 let ns = self.namespace(&repo.resource.namespace())?;
1791 let guard = self.check_guard(&ns, repo);
1792 *self.verify_trust_action.lock().expect("lock poisoned") =
1793 Some(cfg.verify_trust_action.clone());
1794 github_plan(repo, cfg, &self.config.checkout_action, &guard)
1795 }
1796
1797 async fn run_step(&self, repo: &Resource, step: &BootstrapStep) -> Result<StepOutcome> {
1798 match &step.action {
1799 StepAction::WriteFile {
1800 path,
1801 contents,
1802 message,
1803 } => self.write_file(repo, path, contents, message).await,
1804 StepAction::SetVariable { name, value } => self.set_variable(repo, name, value).await,
1805 StepAction::ProtectDefaultBranch(spec) => self.protect(repo, spec).await,
1806 StepAction::RequireNamespaceWorkflow {
1807 contents,
1808 check,
1809 message,
1810 } => {
1811 self.require_namespace_workflow(repo, contents, check, message)
1812 .await
1813 }
1814 StepAction::RequireOwnerReview {
1815 paths,
1816 owners,
1817 community_rules,
1818 message,
1819 } => {
1820 self.require_owner_review(repo, paths, owners, community_rules, message)
1821 .await
1822 }
1823 StepAction::RemoveFile { path, message } => self.remove_file(repo, path, message).await,
1824 StepAction::RemoveVariable { name } => self.remove_variable(repo, name).await,
1825 other => Err(ForgeError::Unsupported {
1826 operation: format!("bootstrap step {other:?}"),
1827 hint: "this GitHub adapter does not know that step".into(),
1828 }),
1829 }
1830 }
1831
1832 async fn pull_request(&self, repo: &Resource, number: u64) -> Result<vgi_forge::PullRequest> {
1833 self.read_pull_request(repo, number).await
1834 }
1835
1836 async fn has_own_comment(&self, repo: &Resource, number: u64, marker: &str) -> Result<bool> {
1837 self.find_own_comment(repo, number, marker).await
1838 }
1839
1840 async fn comment_on_pull_request(
1841 &self,
1842 repo: &Resource,
1843 number: u64,
1844 body: &str,
1845 ) -> Result<()> {
1846 self.post_pull_request_comment(repo, number, body).await
1847 }
1848
1849 async fn close_pull_request(&self, repo: &Resource, number: u64) -> Result<()> {
1850 self.patch_pull_request_closed(repo, number).await
1851 }
1852
1853 fn parse_event(&self, headers: &HeaderMap, body: &[u8]) -> Result<Option<ForgeEvent>> {
1854 webhook::parse(&self.webhook_secret, &self.config.host, headers, body)
1855 }
1856
1857 fn diff(&self, observed: &RepoState, desired: &Projection) -> Vec<Drift> {
1863 let mut drift = default_diff(observed, desired);
1864 if desired.required_check.is_some() {
1865 let ns = self.namespace(&desired.resource.namespace()).ok();
1866 drift.extend(guard::owner_review_drift(ns.as_ref(), observed, desired));
1867 }
1868 guard::merge_protection_drift(drift)
1869 }
1870}
1871
1872impl ForgeHooks for GitHubForge {
1873 fn before_apply_roles(
1878 &self,
1879 repo: &Resource,
1880 desired: &[RoleAssignment],
1881 ) -> HookDecision<Vec<RoleAssignment>> {
1882 let Ok(ns) = self.namespace(&repo.namespace()) else {
1883 return HookDecision::Continue;
1884 };
1885 let kept = self.expressible(&ns, desired);
1886 if kept.len() == desired.len() {
1887 HookDecision::Continue
1888 } else {
1889 HookDecision::Modify(kept)
1890 }
1891 }
1892}
1893
1894fn protection_of(
1898 rs: &RulesetJson,
1899 default_branch: Option<&str>,
1900 actions_id: Option<u64>,
1901) -> ProtectionState {
1902 let mut p = ProtectionState::default();
1903 p.present = true;
1904 p.enforced = rs.enforcement == "active";
1905
1906 let refs = |key: &str| -> Vec<String> {
1907 rs.conditions
1908 .as_ref()
1909 .and_then(|c| c.get("ref_name"))
1910 .and_then(|r| r.get(key))
1911 .and_then(Value::as_array)
1912 .map(|a| {
1913 a.iter()
1914 .filter_map(Value::as_str)
1915 .map(str::to_string)
1916 .collect()
1917 })
1918 .unwrap_or_default()
1919 };
1920 let mut default_names = vec!["~DEFAULT_BRANCH".to_string(), "~ALL".to_string()];
1921 if let Some(b) = default_branch {
1922 default_names.push(format!("refs/heads/{b}"));
1923 }
1924 let (include, exclude) = (refs("include"), refs("exclude"));
1925 p.covers_default_branch = rs.target.as_deref().unwrap_or("branch") == "branch"
1929 && include.iter().any(|r| default_names.contains(r))
1930 && exclude.is_empty();
1931
1932 for rule in &rs.rules {
1933 match rule.kind.as_str() {
1934 "pull_request" => p.requires_pull_request = true,
1935 "non_fast_forward" => p.blocks_force_push = true,
1936 "deletion" => p.blocks_deletion = true,
1937 "required_status_checks" => {
1938 let checks = rule
1939 .parameters
1940 .as_ref()
1941 .and_then(|v| v.get("required_status_checks"))
1942 .and_then(Value::as_array)
1943 .cloned()
1944 .unwrap_or_default();
1945 p.required_checks.extend(checks.iter().filter_map(|c| {
1949 let pinned = actions_id.is_some()
1950 && c.get("integration_id").and_then(Value::as_u64) == actions_id;
1951 pinned
1952 .then(|| c.get("context").and_then(Value::as_str))
1953 .flatten()
1954 .map(str::to_string)
1955 }));
1956 }
1957 _ => {}
1958 }
1959 }
1960
1961 match &rs.bypass_actors {
1962 Some(actors) => {
1963 p.bypass_actors = actors
1964 .iter()
1965 .map(|a| {
1966 format!(
1967 "{}:{}:{}",
1968 a.actor_type,
1969 a.actor_id.map_or_else(|| "-".into(), |i| i.to_string()),
1970 a.bypass_mode.as_deref().unwrap_or("always")
1971 )
1972 })
1973 .collect()
1974 }
1975 None => p.bypass_actors = vec!["<bypass actors not visible to the bridge>".into()],
1977 }
1978 if let Some(mode) = rs.current_user_can_bypass.as_deref()
1979 && mode != "never"
1980 {
1981 p.bypass_actors.push(format!("bridge-app:{mode}"));
1982 }
1983 p
1984}
1985
1986pub fn ruleset_body(spec: &ProtectionSpec, actions_id: Option<u64>) -> Value {
1993 let mut rules = Vec::new();
1994 if spec.block_deletion {
1995 rules.push(json!({ "type": "deletion" }));
1996 }
1997 if spec.block_force_push {
1998 rules.push(json!({ "type": "non_fast_forward" }));
1999 }
2000 if spec.require_pull_request {
2001 rules.push(json!({
2002 "type": "pull_request",
2003 "parameters": {
2004 "required_approving_review_count": u8::from(spec.require_code_owner_review),
2009 "dismiss_stale_reviews_on_push": spec.require_code_owner_review,
2010 "require_code_owner_review": spec.require_code_owner_review,
2011 "require_last_push_approval": spec.require_code_owner_review,
2012 "required_review_thread_resolution": false,
2013 }
2014 }));
2015 }
2016 if let (true, Some(actions_id)) = (spec.require_status_check, actions_id) {
2017 rules.push(json!({
2018 "type": "required_status_checks",
2019 "parameters": {
2020 "strict_required_status_checks_policy": false,
2021 "required_status_checks": [
2022 { "context": spec.required_check, "integration_id": actions_id }
2023 ],
2024 }
2025 }));
2026 }
2027 json!({
2028 "name": RULESET_NAME,
2029 "target": "branch",
2030 "enforcement": "active",
2031 "bypass_actors": [],
2032 "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
2033 "rules": rules,
2034 })
2035}
2036
2037pub fn ruleset_satisfies(
2044 ruleset: &Value,
2045 default_branch: Option<&str>,
2046 actions_id: Option<u64>,
2047 spec: &ProtectionSpec,
2048) -> Result<bool> {
2049 let rs: RulesetJson = serde_json::from_value(ruleset.clone())
2050 .map_err(|e| ForgeError::Protocol(format!("ruleset: {e}")))?;
2051 let observed = protection_of(&rs, default_branch, actions_id);
2052 Ok(satisfies(&observed, spec) && rules_match(&rs, spec))
2053}
2054
2055fn check_variable_name(var: &str) -> Result<()> {
2056 if var.is_empty()
2057 || !var
2058 .bytes()
2059 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
2060 {
2061 return Err(ForgeError::Config(format!(
2062 "variable name `{var}` must be [A-Z0-9_]"
2063 )));
2064 }
2065 Ok(())
2066}
2067
2068fn rules_match(rs: &RulesetJson, spec: &ProtectionSpec) -> bool {
2073 let has_status = rs.rules.iter().any(|r| r.kind == "required_status_checks");
2074 let review = spec.require_code_owner_review;
2075 let pr_ok = !spec.require_pull_request
2076 || rs.rules.iter().any(|r| {
2077 let param = |k: &str| r.parameters.as_ref().and_then(|p| p.get(k)).cloned();
2078 let flag = |k: &str| param(k).and_then(|v| v.as_bool()).unwrap_or(false);
2079 r.kind == "pull_request"
2080 && param("required_approving_review_count")
2081 .and_then(|v| v.as_u64())
2082 .unwrap_or(0)
2083 == u64::from(review)
2084 && flag("require_code_owner_review") == review
2085 && flag("dismiss_stale_reviews_on_push") == review
2086 && flag("require_last_push_approval") == review
2087 });
2088 has_status == spec.require_status_check && pr_ok
2089}
2090
2091fn satisfies(observed: &ProtectionState, spec: &ProtectionSpec) -> bool {
2092 observed.present
2093 && observed.enforced
2094 && observed.covers_default_branch
2095 && observed.bypass_actors.is_empty()
2096 && (!spec.require_status_check || observed.required_checks.contains(&spec.required_check))
2097 && (!spec.require_pull_request || observed.requires_pull_request)
2098 && (!spec.block_force_push || observed.blocks_force_push)
2099 && (!spec.block_deletion || observed.blocks_deletion)
2100}
2101
2102pub(crate) fn next_poll(interval: u64, poll: &TokenPollJson) -> Result<Option<u64>> {
2108 match poll.error.as_deref() {
2109 Some("authorization_pending") => Ok(Some(interval)),
2110 Some("slow_down") => Ok(Some(
2111 poll.interval.unwrap_or(interval + 5).max(interval + 5),
2112 )),
2113 Some("expired_token") => Err(ForgeError::LinkFailed(
2114 "the device code expired before the member approved; start again".into(),
2115 )),
2116 Some("access_denied") => Err(ForgeError::LinkFailed(
2117 "the member declined the authorisation".into(),
2118 )),
2119 Some(other) => Err(ForgeError::LinkFailed(format!(
2120 "{other}: {}",
2121 poll.error_description.as_deref().unwrap_or("")
2122 ))),
2123 None => Err(ForgeError::Protocol(
2124 "token response has neither a token nor an error".into(),
2125 )),
2126 }
2127}
2128
2129fn decode_content(c: &ContentJson) -> Result<Vec<u8>> {
2130 match c.encoding.as_deref() {
2131 Some("base64") => {
2132 let compact: String = c
2133 .content
2134 .as_deref()
2135 .unwrap_or("")
2136 .chars()
2137 .filter(|ch| !ch.is_whitespace())
2138 .collect();
2139 STANDARD
2140 .decode(compact)
2141 .map_err(|e| ForgeError::Protocol(format!("file content: {e}")))
2142 }
2143 Some("none") => Err(ForgeError::Rejected {
2148 status: 409,
2149 message: "the existing file is too large for GitHub to return inline (over 1 MB); \
2150 it was not written by the bootstrap — remove or rename it"
2151 .into(),
2152 }),
2153 other => Err(ForgeError::Protocol(format!(
2154 "file content in unknown encoding {other:?}"
2155 ))),
2156 }
2157}
2158
2159fn role_from_name(name: &str) -> Option<ForgeRole> {
2160 Some(match name {
2161 "admin" => ForgeRole::Admin,
2162 "maintain" => ForgeRole::Maintain,
2163 "write" | "push" => ForgeRole::Write,
2164 "triage" => ForgeRole::Triage,
2165 "read" | "pull" => ForgeRole::Read,
2166 _ => return None,
2167 })
2168}
2169
2170fn put_permission(role: ForgeRole) -> &'static str {
2171 match role {
2172 ForgeRole::Admin => "admin",
2173 ForgeRole::Maintain => "maintain",
2174 ForgeRole::Write => "push",
2175 ForgeRole::Triage => "triage",
2176 _ => "pull",
2177 }
2178}
2179
2180fn invitation_permission(role: ForgeRole) -> &'static str {
2181 match role {
2182 ForgeRole::Admin => "admin",
2183 ForgeRole::Maintain => "maintain",
2184 ForgeRole::Write => "write",
2185 ForgeRole::Triage => "triage",
2186 _ => "read",
2187 }
2188}
2189
2190#[derive(Deserialize)]
2193struct RepoJson {
2194 id: u64,
2195 full_name: String,
2196 #[serde(default)]
2197 visibility: Option<String>,
2198 #[serde(default)]
2199 private: bool,
2200 #[serde(default)]
2201 archived: bool,
2202 #[serde(default)]
2203 default_branch: Option<String>,
2204}
2205
2206#[derive(Deserialize)]
2207struct UserJson {
2208 id: u64,
2209 login: String,
2210}
2211
2212#[derive(Deserialize)]
2215struct PermissionJson {
2216 #[serde(default)]
2217 permission: String,
2218 #[serde(default)]
2219 role_name: Option<String>,
2220}
2221
2222#[derive(Deserialize)]
2224struct MembershipJson {
2225 #[serde(default)]
2226 state: String,
2227 #[serde(default)]
2228 role: String,
2229}
2230
2231#[derive(Deserialize)]
2232struct TeamJson {
2233 name: String,
2234 slug: String,
2235}
2236
2237#[derive(Deserialize)]
2238struct CollaboratorJson {
2239 id: u64,
2240 login: String,
2241 #[serde(default)]
2242 role_name: Option<String>,
2243 #[serde(default)]
2244 permissions: Option<PermsJson>,
2245}
2246
2247impl CollaboratorJson {
2248 fn role(&self) -> ForgeRole {
2250 if let Some(role) = self.role_name.as_deref().and_then(role_from_name) {
2251 return role;
2252 }
2253 let p = self.permissions.as_ref();
2254 let has = |f: fn(&PermsJson) -> bool| p.is_some_and(f);
2255 if has(|p| p.admin) {
2256 ForgeRole::Admin
2257 } else if has(|p| p.maintain) {
2258 ForgeRole::Maintain
2259 } else if has(|p| p.push) {
2260 ForgeRole::Write
2261 } else if has(|p| p.triage) {
2262 ForgeRole::Triage
2263 } else {
2264 ForgeRole::Read
2265 }
2266 }
2267}
2268
2269#[derive(Deserialize, Default)]
2270#[serde(default)]
2271struct PermsJson {
2272 admin: bool,
2273 maintain: bool,
2274 push: bool,
2275 triage: bool,
2276}
2277
2278#[derive(Deserialize)]
2279struct InvitationJson {
2280 id: u64,
2281 #[serde(default)]
2282 invitee: Option<UserJson>,
2283 permissions: String,
2284}
2285
2286#[derive(Deserialize)]
2287struct RulesetSummary {
2288 id: u64,
2289 name: String,
2290}
2291
2292#[derive(Deserialize)]
2293struct RulesetJson {
2294 id: u64,
2295 #[serde(default)]
2296 target: Option<String>,
2297 enforcement: String,
2298 #[serde(default)]
2301 bypass_actors: Option<Vec<BypassJson>>,
2302 #[serde(default)]
2303 current_user_can_bypass: Option<String>,
2304 #[serde(default)]
2305 conditions: Option<Value>,
2306 #[serde(default)]
2307 rules: Vec<RuleJson>,
2308}
2309
2310#[derive(Deserialize)]
2311struct BypassJson {
2312 #[serde(default)]
2313 actor_id: Option<u64>,
2314 actor_type: String,
2315 #[serde(default)]
2316 bypass_mode: Option<String>,
2317}
2318
2319#[derive(Deserialize)]
2320struct RuleJson {
2321 #[serde(rename = "type")]
2322 kind: String,
2323 #[serde(default)]
2324 parameters: Option<Value>,
2325}
2326
2327#[derive(Deserialize)]
2328struct InstallationJson {
2329 id: u64,
2330 account: AccountJson,
2331 #[serde(default)]
2332 permissions: BTreeMap<String, String>,
2333 #[serde(default)]
2334 events: Vec<String>,
2335 #[serde(default)]
2336 suspended_at: Option<String>,
2337}
2338
2339fn installation_missing(inst: &InstallationJson) -> Vec<String> {
2342 let mut missing = missing_permissions(&inst.permissions);
2343 missing.extend(
2344 crate::manifest::CHECK_EVENTS
2345 .iter()
2346 .filter(|e| !inst.events.iter().any(|x| x == *e))
2347 .map(|e| format!("event:{e}")),
2348 );
2349 missing
2350}
2351
2352#[derive(Deserialize)]
2353struct AccountJson {
2354 id: u64,
2355 login: String,
2356 #[serde(rename = "type")]
2357 kind: String,
2358}
2359
2360#[derive(Deserialize)]
2361struct ContentJson {
2362 sha: String,
2363 #[serde(rename = "type")]
2364 kind: String,
2365 #[serde(default)]
2366 content: Option<String>,
2367 #[serde(default)]
2368 encoding: Option<String>,
2369}
2370
2371#[derive(Deserialize)]
2372struct DeviceCodeJson {
2373 device_code: Option<String>,
2374 user_code: Option<String>,
2375 verification_uri: Option<String>,
2376 expires_in: Option<u64>,
2377 interval: Option<u64>,
2378 error: Option<String>,
2379 error_description: Option<String>,
2380}
2381
2382#[derive(Deserialize, Default)]
2383pub(crate) struct TokenPollJson {
2384 pub(crate) access_token: Option<String>,
2385 pub(crate) error: Option<String>,
2386 pub(crate) error_description: Option<String>,
2387 pub(crate) interval: Option<u64>,
2388}
2389
2390impl std::fmt::Debug for TokenPollJson {
2391 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2392 f.debug_struct("TokenPollJson")
2393 .field(
2394 "access_token",
2395 &self.access_token.as_ref().map(|_| "<redacted>"),
2396 )
2397 .field("error", &self.error)
2398 .field("interval", &self.interval)
2399 .finish()
2400 }
2401}
2402
2403#[cfg(test)]
2404mod tests {
2405 use super::*;
2406
2407 fn poll(error: &str, interval: Option<u64>) -> TokenPollJson {
2408 TokenPollJson {
2409 error: Some(error.into()),
2410 interval,
2411 ..TokenPollJson::default()
2412 }
2413 }
2414
2415 #[test]
2416 fn device_polling_backs_off_on_slow_down() {
2417 assert_eq!(
2418 next_poll(5, &poll("authorization_pending", None)).unwrap(),
2419 Some(5)
2420 );
2421 assert_eq!(next_poll(5, &poll("slow_down", None)).unwrap(), Some(10));
2422 assert_eq!(
2423 next_poll(5, &poll("slow_down", Some(15))).unwrap(),
2424 Some(15)
2425 );
2426 assert_eq!(next_poll(5, &poll("slow_down", Some(1))).unwrap(), Some(10));
2428 assert!(matches!(
2429 next_poll(5, &poll("expired_token", None)),
2430 Err(ForgeError::LinkFailed(_))
2431 ));
2432 assert!(matches!(
2433 next_poll(5, &poll("access_denied", None)),
2434 Err(ForgeError::LinkFailed(_))
2435 ));
2436 }
2437
2438 #[test]
2439 fn roles_map_both_ways() {
2440 for role in [
2441 ForgeRole::Read,
2442 ForgeRole::Triage,
2443 ForgeRole::Write,
2444 ForgeRole::Maintain,
2445 ForgeRole::Admin,
2446 ] {
2447 assert_eq!(role_from_name(invitation_permission(role)), Some(role));
2448 assert_eq!(role_from_name(put_permission(role)), Some(role));
2449 }
2450 let custom = CollaboratorJson {
2451 id: 1,
2452 login: "x".into(),
2453 role_name: Some("security-reviewer".into()),
2454 permissions: Some(PermsJson {
2455 triage: true,
2456 ..PermsJson::default()
2457 }),
2458 };
2459 assert_eq!(custom.role(), ForgeRole::Triage);
2460 }
2461}