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