Skip to main content

vgi_forge_github/
forge.rs

1//! [`GitHubForge`]: the `Forge` implementation.
2
3use 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
35/// The role ladder of an organisation repository.
36const ORG_LADDER: [ForgeRole; 5] = [
37    ForgeRole::Read,
38    ForgeRole::Triage,
39    ForgeRole::Write,
40    ForgeRole::Maintain,
41    ForgeRole::Admin,
42];
43
44/// A personal account's repositories have collaborators, and collaborators
45/// are always `write` (§3, §8).
46const USER_LADDER: [ForgeRole; 1] = [ForgeRole::Write];
47
48/// Installation-token permissions per kind of job. Each token also names
49/// the one repository it is for wherever a repository exists yet.
50///
51/// `inspect` asks for administration *write* although it only reads: GitHub
52/// shows a ruleset's bypass actors only to a caller who could edit it, and
53/// an inspect that could not see them would report "no bypass" for a ruleset
54/// that has one.
55const 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")];
60/// Reading someone's access to a repository and — in an organisation —
61/// the teams and membership it comes from.
62const PERMS_ACCESS: &[(&str, &str)] = &[("administration", "read"), ("metadata", "read")];
63const PERMS_ACCESS_ORG: &[(&str, &str)] = &[
64    ("administration", "read"),
65    ("members", "read"),
66    ("metadata", "read"),
67];
68/// Org rulesets. *Write* even to read them: GitHub lists every
69/// `/orgs/{org}/rulesets` endpoint under organization Administration
70/// (write), and shows bypass actors only to a caller who could edit them.
71/// Minted with no repositories: it grants nothing on any repository.
72const PERMS_ORG_RULESETS: &[(&str, &str)] = &[("organization_administration", "write")];
73
74/// The namespace workflow an org ruleset pins (§9): which `.vgi` commit it
75/// runs, and the check name its job reports.
76///
77/// The bridge records one per namespace when the `required-workflow` step
78/// runs, and `inspect` compares the org ruleset against it: a pin the bridge
79/// did not make is drift. Persist it (it is small and not secret) and hand it
80/// back with [`GitHubForge::set_required_workflow_pin`] after a restart;
81/// without it, `inspect` reports the pin as unverified until the step runs
82/// again.
83#[derive(Debug, Clone, PartialEq, Eq)]
84#[non_exhaustive]
85pub struct RequiredWorkflowPin {
86    /// Numeric id of `<org>/.vgi`.
87    pub repository_id: u64,
88    /// The pinned commit.
89    pub sha: String,
90    /// The check (job) name the workflow reports.
91    pub check: String,
92}
93
94impl RequiredWorkflowPin {
95    /// A pin.
96    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
105/// How long GitHub lets a device code live (15 minutes). Polling never runs
106/// longer, whatever the caller says.
107const DEVICE_CODE_MAX_LIFETIME_SECS: u64 = 900;
108
109/// Shortest bind `state` accepted: 128 bits of base64url.
110const MIN_STATE_LEN: usize = 22;
111
112/// The GitHub adapter: one community's App on one GitHub (github.com or a
113/// GHES instance).
114///
115/// Holds the App's key (behind [`AppKeySigner`]), its webhook secret, and
116/// the namespaces the core has bound. It holds no forge token between calls:
117/// each operation mints an installation token scoped to the one repository
118/// and the permissions that operation needs, and drops it on return.
119pub 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    /// Per organisation: whether org rulesets (and so a required workflow)
128    /// are available. Absent means not known, which plans the owner-review
129    /// fallback — safe everywhere.
130    required_workflow: RwLock<BTreeMap<Resource, bool>>,
131    /// Per namespace: whether its installation grants what the
132    /// bridge-posted check needs (permissions and event subscriptions).
133    /// Absent means not known, which keeps the in-repo workflow.
134    check_ready: RwLock<BTreeMap<Resource, bool>>,
135    pins: RwLock<BTreeMap<Resource, RequiredWorkflowPin>>,
136    /// Per organisation: the forge ids of the repositories the bridge
137    /// manages — what the org ruleset lists. From the bridge's store.
138    managed: RwLock<BTreeMap<Resource, BTreeSet<u64>>>,
139    /// One org-ruleset read-modify-write at a time per namespace.
140    org_locks: Mutex<BTreeMap<Resource, Arc<tokio::sync::Mutex<()>>>>,
141    /// The verify-trust action the last plan used, for the Actions-policy
142    /// check in `inspect`.
143    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    /// An adapter for `config`'s App, signing with `signer` and verifying
159    /// webhooks with `webhook_secret`.
160    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    /// Give the adapter the App's OAuth client secret (from the manifest
203    /// exchange). With it, the member's user token from an account link is
204    /// revoked as soon as their id is read; without it the token is only
205    /// dropped and lapses on its own (eight hours for an expiring App user
206    /// token), because revocation is authenticated with the client secret.
207    pub fn with_client_secret(mut self, secret: Secret) -> Self {
208        self.client_secret = Some(secret);
209        self
210    }
211
212    /// The configuration.
213    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    /// An installation token for `repo` alone, with `perms`, plus its owner
226    /// and name — for the crate's other modules.
227    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    /// Tell the adapter about a bound namespace (from the VTC's store, after
236    /// the admin confirmed the bind). Operations on repositories in a
237    /// namespace that was never registered are refused with
238    /// [`ForgeError::NotBound`]: the binding, not whatever the App happens
239    /// to be installed on, is what authorises the bridge to act.
240    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    /// Forget a namespace (unbind).
255    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    /// Tell the adapter which repositories (by forge id) it manages in
270    /// organisation `ns`, from the bridge's store: at start-up and whenever
271    /// the set changes. The org ruleset lists exactly these (plus a
272    /// repository being bootstrapped); ids GitHub lists that are not here —
273    /// archived, deleted or never managed — are dropped from it. Until it
274    /// is set, the `required-workflow` step refuses to run rather than
275    /// guess the set from GitHub.
276    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    /// The managed set as the adapter holds it (it adds each repository it
284    /// bootstraps under a required workflow, and drops each it archives).
285    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    /// Record whether `ns`'s installation carries the bridge-posted check
299    /// (from [`GitHubForge::detect_bridge_checks`], or the bridge's store
300    /// after a restart). Until it is known, a namespace without a required
301    /// workflow keeps the in-repo Actions workflow.
302    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    /// Whether `ns`'s installation is known to carry the bridge-posted
310    /// check (`None`: not known yet).
311    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    /// Read `ns`'s installation and record whether it grants what the
320    /// bridge-posted check needs: `checks: write`, `pull_requests: read`,
321    /// `merge_queues: read` and the `pull_request` and `merge_group`
322    /// subscriptions ([`crate::manifest::check_ready`]). An App registered
323    /// before these were in the manifest lacks them until its owner updates
324    /// the App's settings and each installation approves the change; the
325    /// bridge probes again when an installation accepts new permissions.
326    pub async fn detect_bridge_checks(&self, ns: &Resource) -> Result<bool> {
327        Ok(self.detect_installation(ns).await?.0)
328    }
329
330    /// [`GitHubForge::detect_bridge_checks`], and what the installation
331    /// lacks of what the App asks for (as `name:level`, and `event:<name>`
332    /// for a missing subscription) — the same list a bind reports in
333    /// [`NamespaceBinding::missing_permissions`], read again now (after an
334    /// owner approved an upgrade, say).
335    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    /// Record whether org rulesets — and so a required workflow — are
359    /// available in organisation `ns` (from [`GitHubForge::detect_required_workflow`],
360    /// or from the bridge's store after a restart). Until it is known the
361    /// namespace plans the owner-review fallback.
362    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    /// Restore the pin the `required-workflow` step last made in `ns`.
370    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    /// The pin the `required-workflow` step last made in `ns`, to persist.
378    pub fn required_workflow_pin(&self, ns: &Resource) -> Option<RequiredWorkflowPin> {
379        self.pins.read().expect("lock poisoned").get(ns).cloned()
380    }
381
382    /// Find out whether organisation `ns` can have a required workflow, and
383    /// record the answer.
384    ///
385    /// The probe is `GET /orgs/{org}/rulesets` with an organization
386    /// Administration token. Org rulesets exist on GitHub Team and
387    /// Enterprise plans only; on a Free organisation, or where the owner has
388    /// not granted the App organization Administration, GitHub refuses
389    /// (403/404, or 422 when minting the token) and the answer is `false`.
390    /// A personal account, or manual mode, is always `false`. Network and
391    /// rate-limit failures are returned, not guessed at.
392    ///
393    /// A `true` here is necessary, not sufficient: GitHub documents the
394    /// workflows rule for Enterprise Cloud. If the org ruleset is then
395    /// refused, the `required-workflow` step records `false` and asks for a
396    /// re-plan.
397    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    /// A fresh bind `state` nonce: 256 bits from the system CSPRNG,
437    /// base64url. The caller stores it with its expiry and hands it back to
438    /// [`Forge::complete_bind`].
439    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    /// Download GitHub's `web-flow` public key (`<web>/web-flow.gpg`) for the
447    /// platform keyring. Never called implicitly: the keyring is
448    /// configuration, and fetching it is a choice the operator makes and
449    /// can review.
450    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    // ── credentials ──────────────────────────────────────────────────────
463
464    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    /// Mint an installation token for `ns`, limited to `repo` (when given)
473    /// and `perms`. Dropped by the caller when the operation returns.
474    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                // GitHub answers 422 when a named repository is not in the
516                // installation — for the caller that is "not found".
517                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    /// Check `repo` is a repository on this forge and return its namespace,
543    /// owner and name.
544    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        // A `Resource` from a bridge job was validated against the general
552        // grammar (any depth). Splitting `github.com/acme/evil/widgets` into
553        // first and last segment would act on `acme/widgets`.
554        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    /// The GitHub Actions App's id — what the required check is pinned to.
573    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    /// `DELETE /applications/{client_id}/token`, authenticated with the
596    /// client id and secret. Best effort: the link already succeeded, and a
597    /// token that could not be revoked still lapses on its own — so a
598    /// failure is logged, not returned.
599    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    // ── reads ────────────────────────────────────────────────────────────
617
618    /// Where repository `id` is now, as namespace `ns`'s installation sees it
619    /// (`GET /repositories/{id}` with that installation's token): `None` if
620    /// it cannot see it. What a transfer into `ns` is confirmed by — GitHub's
621    /// word, not a webhook's.
622    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    // ── bootstrap steps ──────────────────────────────────────────────────
714
715    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    /// [`GitHubForge::write_file`] with a contents token already in hand.
729    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        // A directory at `path` answers with a JSON array, a file with an
744        // object: read it untyped first so the conflict is reported as one.
745        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    /// The App a required check is pinned to: this App where the bridge
847    /// posts the check itself, the GitHub Actions App otherwise.
848    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    /// Converge the managed ruleset on `owner/name` to `spec`, with a token
857    /// holding administration on it. `bridge_posted` pins the required check
858    /// to this App instead of GitHub Actions.
859    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        // Looked up only when this rule carries the check: under a required
868        // workflow the org ruleset does.
869        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    /// Make sure `path` is absent from the default branch.
924    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    /// Make sure CI variable `var` is absent.
956    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    // ── roles ────────────────────────────────────────────────────────────
977
978    /// Whether `id` is the account holder of a personal-account namespace:
979    /// the repository's implicit admin, never a collaborator to add, report
980    /// or remove.
981    fn is_personal_owner(ns: &Namespace, id: u64) -> bool {
982        ns.kind == NamespaceKind::User && Some(id) == ns.owner_id
983    }
984
985    /// Drop assignments GitHub cannot express: the owner of a personal
986    /// account is its implicit admin and cannot be added as a collaborator.
987    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        // The numeric id is the binding; the login is looked up fresh so a
997        // renamed-and-re-registered login never receives the role.
998        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                // Personal-account repos take no permission: collaborators
1065                // there are always `write`.
1066                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    /// Where `login`'s access to an organisation's repository comes from,
1082    /// other than a direct role: owning the organisation, the teams with
1083    /// access to the repository that they are in, or — failing both — the
1084    /// organisation's base permission for members. Best effort: a lookup
1085    /// GitHub refuses leaves that source out, and the access is still
1086    /// reported.
1087    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
1132/// Where someone stands on a repository before a change.
1133enum 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        // Without a namespace workflow the bridge posts the check itself
1168        // when configured to (§9, forged check runs): then nothing in the
1169        // repository is on the check's path at all.
1170        c.bridge_posted_check = automated
1171            && !c.required_workflow
1172            && self.config.bridge_checks
1173            && self.bridge_checks_ready(&ns.resource) == Some(true);
1174        // Otherwise a single-owner repository gets no review requirement on
1175        // its workflow (the user's decision: there is nobody else to review,
1176        // and its owner controls the repository anyway).
1177        c.single_owner_repos_unreviewed = !c.required_workflow && !c.bridge_posted_check;
1178        match ns.kind {
1179            NamespaceKind::User => {
1180                // §8: only the account holder can create repositories, and
1181                // every collaborator is `write`.
1182                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        // Authenticated as the App, this only finds installations of *this*
1254        // App — another App's id is a 404.
1255        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        // Whether this org can have a required workflow (§9). Best effort:
1297        // a failure leaves it unknown, which plans the owner-review fallback
1298        // until `detect_required_workflow` is run again.
1299        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        // Whether the installation carries the bridge-posted check: its
1310        // permissions *and* its event subscriptions (an App registered before
1311        // they were in the manifest lacks both until its owner approves).
1312        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        // Handed back as data for the bridge to persist; the adapter's copy
1318        // is in memory only.
1319        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        // `expires_in` comes back from the caller, not from GitHub; never
1369        // poll longer than GitHub lets a device code live.
1370        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        // The bridge needs the id, not a standing credential for the
1401        // member's account: revoke the token when we can, and drop (wipe) it
1402        // either way.
1403        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            // Only a check pinned to the App that should post it counts.
1444            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            // Actions runs the check only where the bridge does not.
1451            self.inspect_actions_policy(&token, &owner, &name, &mut state.protection)
1452                .await?;
1453        }
1454        drop(token);
1455        if caps.bridge_posted_check {
1456            // Nothing in the repository is on the check's path: the bridge
1457            // runs verify-trust from its own build and posts as its App.
1458            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        // No repository to scope to yet: this token is org-wide, but only
1489        // for administration. Accepted residual (review F6): for the life of
1490        // this one call the token could administer every repository the
1491        // installation covers. GitHub offers no narrower grant for
1492        // `POST /orgs/{org}/repos`; the token is not reused and is dropped on
1493        // return.
1494        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            // A first commit gives the repo a default branch for the
1518            // bootstrap to commit to and the ruleset to cover.
1519            "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            // An archived repository takes no pull requests: the org ruleset
1567            // drops it on its next convergence.
1568            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            // Round down onto the ladder again: never trust the caller to
1597            // have done it, and never ask GitHub for more than it offers.
1598            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 &current {
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        // The App's own bot user, by the login GitHub reports for it now: a
1666        // `[bot]` login cannot be registered by a person.
1667        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                // Credentials and rate limits fail the whole job; anything
1689                // else is this one person's problem.
1690                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    /// GitHub's effective permission for the account
1703    /// (`/collaborators/{username}/permission` counts teams, organisation
1704    /// ownership and the base permission), then where it comes from.
1705    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            // No such account any more: it has no access.
1717            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            // Everyone reads a public repository (and every enterprise
1741            // member an internal one): that is no access to report.
1742            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    /// The default comparison, plus the owner-review guard against the
1819    /// projection's owners (§9, the user's decision on solo repositories):
1820    /// two or more owners must all be reviewers of a healthy guard; one
1821    /// owner needs no guard, and a guard left over from when there were
1822    /// more is a re-plan (it would lock the solo owner out).
1823    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    /// In a personal-account namespace the owner is the repository's
1835    /// implicit admin and GitHub refuses to add them as a collaborator, so
1836    /// they are dropped from the desired set before it reaches GitHub (and
1837    /// before the core reports their "missing" role as drift).
1838    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
1855/// What a ruleset enforces, read from GitHub's own shape of it. A required
1856/// check counts only when pinned to `actions_id` (the App the check must
1857/// come from).
1858fn 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    // Any exclusion at all counts as not covering: `refs/heads/*` or a
1887    // pattern matching the default branch excludes it as surely as its
1888    // literal name, and the managed ruleset is created with none.
1889    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                // Only a check pinned to the Actions App counts: an
1907                // unpinned one is satisfied by any status of that name,
1908                // which anyone with write access can post.
1909                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        // Not visible to us is not the same as none: fail closed.
1937        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
1947/// The ruleset GitHub is asked for: default branch, PR required, the check
1948/// required and pinned to the Actions App, no force-push, no deletion, and
1949/// an empty bypass list.
1950///
1951/// Public so a client acting as the repository's own admin (`vgi repo
1952/// init`) asks GitHub for exactly the ruleset the bridge would.
1953pub 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                // Owner review (§9): one approval, a code owner's where one
1966                // is named, dismissed by any later push, and not the last
1967                // pusher's own — or a reviewed change could be swapped after
1968                // approval.
1969                "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
1998/// Whether `ruleset` — GitHub's JSON for one repository ruleset, as `GET
1999/// /repos/{owner}/{repo}/rulesets/{id}` returns it — already enforces `spec`
2000/// on a repository whose default branch is `default_branch`, with the
2001/// required check pinned to `actions_id`. The same test the adapter runs
2002/// before it rewrites its managed ruleset, so a client that converges on it
2003/// leaves a ruleset alone exactly when the bridge would.
2004pub 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
2029/// Whether the ruleset's pull-request parameters and status-check rule are
2030/// exactly what `spec` asks for — no stricter either: a leftover code-owner
2031/// requirement locks a solo owner out, and a leftover status check is the
2032/// old guard's (L4).
2033fn 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
2063/// The next poll interval after a device-flow poll that returned no token:
2064/// `Some(interval)` to keep polling, `Err` to stop.
2065///
2066/// `slow_down` adds five seconds (RFC 8628 §3.5) unless GitHub names the new
2067/// interval itself.
2068pub(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        // Over 1 MB GitHub returns `encoding: "none"` and no content. Nothing
2105        // the bootstrap writes is that large, so a file that is must have
2106        // been put there by someone else: refuse rather than overwrite what
2107        // we cannot see.
2108        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// ── wire shapes ──────────────────────────────────────────────────────────
2152
2153#[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/// `GET /repos/{owner}/{repo}/collaborators/{username}/permission`: the
2174/// account's effective permission, whatever it comes from.
2175#[derive(Deserialize)]
2176struct PermissionJson {
2177    #[serde(default)]
2178    permission: String,
2179    #[serde(default)]
2180    role_name: Option<String>,
2181}
2182
2183/// An organisation or team membership.
2184#[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    /// `role_name`, or — for a custom role — the highest base permission.
2210    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    /// Absent when the caller may not edit the ruleset — which is exactly
2260    /// when it must not be read as "none".
2261    #[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
2300/// What an installation lacks of what the App asks for: permissions as
2301/// `name:level`, subscriptions as `event:<name>`.
2302fn 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        // A smaller server-named interval never speeds us up past +5.
2388        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}