vgi_forge/forge.rs
1//! The [`Forge`] trait (§5.8).
2
3use async_trait::async_trait;
4use http::HeaderMap;
5
6use crate::bootstrap::{BootstrapStep, StepOutcome, VgiConfig};
7use crate::error::{ForgeError, Result};
8use crate::event::{Drift, ForgeEvent, default_diff};
9use crate::model::{
10 ApplyReport, BindCallback, BindRequest, BindStep, Capabilities, ForgeAccount, ForgeKind,
11 IndirectAccess, LinkCallback, LinkStep, Namespace, NamespaceBinding, Projection, RepoSpec,
12 RepoState, RoleAssignment, Unlisted,
13};
14use crate::pulls::PullRequest;
15use crate::resource::Resource;
16use crate::rights::{EffectiveRights, ForgeRole, RoleMap, collapse_to_ladder};
17
18/// One forge implementation. Stateless apart from its credentials and the
19/// namespaces it has been told about; the core owns all desired state and
20/// hands the adapter a plan.
21///
22/// Object-safe (through `async-trait`) so a bridge can hold one
23/// `Box<dyn Forge>` per forge host and dispatch on a resource's host. Methods
24/// with a sensible forge-neutral answer have a default; an adapter overrides
25/// only what its forge does differently.
26#[async_trait]
27pub trait Forge: Send + Sync {
28 /// Which forge software this is.
29 fn kind(&self) -> ForgeKind;
30
31 /// The forge host this adapter serves (`github.com`, a GHES host,
32 /// `codeberg.org`). Every resource it accepts starts with it; a
33 /// resource on another host is refused rather than sent to the wrong
34 /// forge.
35 fn host(&self) -> &str;
36
37 /// What this forge, and this namespace on it, can do. The core and the
38 /// UX branch on this, never on [`Forge::kind`].
39 fn capabilities(&self, ns: &Namespace) -> Capabilities;
40
41 // ── identity and binding ─────────────────────────────────────────────
42
43 /// Start binding a namespace: where to send the admin.
44 async fn begin_bind(&self, req: BindRequest) -> Result<BindStep>;
45
46 /// Finish a bind from the forge's callback. Validates the state nonce and
47 /// that the credential landed on the expected owner.
48 async fn complete_bind(&self, cb: BindCallback) -> Result<NamespaceBinding>;
49
50 /// Start linking a member's forge account. `member` is their DID, for
51 /// the adapter's audit trail; nothing forge-side sees it.
52 async fn begin_account_link(&self, member: &str) -> Result<LinkStep>;
53
54 /// Finish linking: the account's numeric id and current login.
55 async fn complete_account_link(&self, cb: LinkCallback) -> Result<ForgeAccount>;
56
57 // ── resources ────────────────────────────────────────────────────────
58
59 /// Canonical form of a forge path. The default applies the
60 /// `owner[/repo]` grammar GitHub and Forgejo share and refuses a
61 /// resource on another host.
62 fn normalize(&self, raw: &str) -> Result<Resource> {
63 let resource = Resource::parse_owner_repo(raw)?;
64 if resource.host() != self.host() {
65 return Err(ForgeError::WrongResource {
66 resource: resource.to_string(),
67 expected: format!("a resource on `{}`", self.host()),
68 });
69 }
70 Ok(resource)
71 }
72
73 /// Observe a repository's current state.
74 async fn inspect(&self, repo: &Resource) -> Result<RepoState>;
75
76 // ── repo lifecycle ───────────────────────────────────────────────────
77
78 /// Create a repository. Refuses one that already exists with
79 /// [`ForgeError::AlreadyExists`] — adopting it is a separate, elevated
80 /// decision (§5.6), not something a retry should do silently.
81 async fn create_repo(&self, spec: &RepoSpec) -> Result<RepoState>;
82
83 /// Archive a repository. Idempotent.
84 async fn archive_repo(&self, repo: &Resource) -> Result<()>;
85
86 // ── projection ───────────────────────────────────────────────────────
87
88 /// Rights → this forge's role for one person on one repository in `ns`.
89 /// The default asks `map` for a role and rounds it down onto the
90 /// namespace's ladder.
91 fn map_role(&self, ns: &Namespace, rights: EffectiveRights, map: &RoleMap) -> ForgeRole {
92 collapse_to_ladder(map.requested(rights), &self.capabilities(ns).role_levels)
93 }
94
95 /// Converge people's direct roles on a repository to `desired`.
96 /// Collaborators `desired` does not mention are handled per `unlisted`.
97 async fn apply_roles(
98 &self,
99 repo: &Resource,
100 desired: &[RoleAssignment],
101 unlisted: Unlisted,
102 ) -> Result<ApplyReport>;
103
104 /// Whether the account with forge id `account` must never be taken off a
105 /// repository in `ns`, whatever a job asks: the namespace's owner (on a
106 /// personal account, the implicit admin of every repository in it) and
107 /// the adapter's own automation identity (a Forgejo bot, a GitHub App's
108 /// bot user), without which nothing the bridge does would keep working.
109 ///
110 /// Matched by numeric id, never by login. The default protects the
111 /// owner; an adapter that knows its automation account's id adds it.
112 fn is_protected_account(&self, ns: &Namespace, account: u64) -> bool {
113 ns.owner_id == Some(account)
114 }
115
116 /// Access `account` has to `repo` that is not a direct role on it —
117 /// through a team, as an owner or a member of the organisation — above
118 /// what anyone has anyway (`read` on a repository everyone can read).
119 /// `Ok(None)`: none.
120 ///
121 /// Asked after the account's direct role was taken away
122 /// (`git-ns/bridge/job` 0.2 `removeAccounts`), so that access the job
123 /// could not remove is reported. It only reads: teams and organisation
124 /// membership are never changed to satisfy a job about one repository.
125 /// The default knows of no access other than direct roles.
126 async fn indirect_access(
127 &self,
128 repo: &Resource,
129 account: &ForgeAccount,
130 ) -> Result<Option<IndirectAccess>> {
131 let _ = (repo, account);
132 Ok(None)
133 }
134
135 /// The steps that turn commit trust on for this forge's CI.
136 fn bootstrap_plan(&self, repo: &RepoSpec, cfg: &VgiConfig) -> Result<Vec<BootstrapStep>>;
137
138 /// Run one step, check-then-apply.
139 async fn run_step(&self, repo: &Resource, step: &BootstrapStep) -> Result<StepOutcome>;
140
141 // ── pull requests (the pull-request gate) ────────────────────────────
142 //
143 // `git-ns/bridge/job` 0.5 `closePullRequest`. The defaults refuse with
144 // [`ForgeError::Unsupported`] (the job's `notCapable`): a forge whose
145 // adapter does not implement them has no pull-request gate, and a pull
146 // request simply stays open — the required check still guards merges.
147
148 /// Read pull request `number` of `repo` now: open, closed or merged, and
149 /// the most recent reopen by an account other than the adapter's own.
150 /// [`ForgeError::NotFound`] when there is no such pull request.
151 async fn pull_request(&self, repo: &Resource, number: u64) -> Result<PullRequest> {
152 let _ = (repo, number);
153 Err(pulls_unsupported())
154 }
155
156 /// Whether pull request `number` of `repo` already carries a comment
157 /// **the adapter's own account** posted that contains `marker` — how a
158 /// retried `closePullRequest` finds the comment an earlier attempt
159 /// posted. A comment by anyone else never counts, whatever it contains.
160 async fn has_own_comment(&self, repo: &Resource, number: u64, marker: &str) -> Result<bool> {
161 let _ = (repo, number, marker);
162 Err(pulls_unsupported())
163 }
164
165 /// Post `body` as a comment on pull request `number` of `repo`, verbatim.
166 async fn comment_on_pull_request(
167 &self,
168 repo: &Resource,
169 number: u64,
170 body: &str,
171 ) -> Result<()> {
172 let _ = (repo, number, body);
173 Err(pulls_unsupported())
174 }
175
176 /// Close pull request `number` of `repo` without merging it.
177 async fn close_pull_request(&self, repo: &Resource, number: u64) -> Result<()> {
178 let _ = (repo, number);
179 Err(pulls_unsupported())
180 }
181
182 // ── events and drift ─────────────────────────────────────────────────
183
184 /// Verify and translate a webhook. `Ok(None)` for a verified delivery
185 /// the core has no use for; `Err` for one that failed verification —
186 /// which must not be acted on.
187 fn parse_event(&self, headers: &HeaderMap, body: &[u8]) -> Result<Option<ForgeEvent>>;
188
189 /// Compare observed state with the projection.
190 fn diff(&self, observed: &RepoState, desired: &Projection) -> Vec<Drift> {
191 default_diff(observed, desired)
192 }
193}
194
195fn pulls_unsupported() -> ForgeError {
196 ForgeError::Unsupported {
197 operation: "closePullRequest".into(),
198 hint: "this forge adapter has no pull-request gate; the pull request stays open, and \
199 the required commit-trust check still guards what merges"
200 .into(),
201 }
202}