use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, RwLock};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use http::HeaderMap;
use reqwest::Method;
use serde::{Deserialize, Deserializer};
use serde_json::{Value, json};
use vgi_forge::{
AccessSource, ApplyReport, BindCallback, BindRequest, BindStep, BootstrapStep, Capabilities,
Collaborator, Drift, Forge, ForgeAccount, ForgeError, ForgeEvent, ForgeHooks, ForgeKind,
ForgeRole, HookDecision, IndirectAccess, LinkCallback, LinkMethod, LinkStep, MergeMethod,
Namespace, NamespaceBinding, NamespaceKind, Projection, ProtectionGap, ProtectionSpec,
ProtectionState, RepoSettings, RepoSpec, RepoState, RequiredCheckKind, Resource, Result,
RoleAssignment, RoleChange, RoleOutcome, StepAction, StepOutcome, Unlisted, VgiConfig,
Visibility, async_trait, collapse_to_ladder, default_diff, validate_repo_path,
};
use crate::api::{Api, Auth};
use crate::config::{Credentials, ForgejoConfig, MergeFallback, TokenRotation, check_login};
use crate::oauth::{OAuthKeys, Purpose, TokenJson, unix_now};
use crate::plan::{MergePlan, PROTECTED_PATHS, PlanOptions, forgejo_plan};
use crate::secret::Secret;
use crate::version::InstanceInfo;
use crate::webhook::{self, HOOK_EVENTS};
pub const BOT_TOKEN_SCOPES: [&str; 3] = ["write:organization", "write:repository", "read:user"];
pub const TOKEN_NAME_PREFIX: &str = "vgi-bridge-";
const LADDER: [ForgeRole; 4] = [
ForgeRole::Read,
ForgeRole::Write,
ForgeRole::Maintain,
ForgeRole::Admin,
];
const MIN_STATE_LEN: usize = 22;
const TEAM_UNITS: [&str; 3] = ["repo.code", "repo.pulls", "repo.actions"];
#[derive(Debug, Clone)]
struct Probed {
info: InstanceInfo,
bot: ForgeAccount,
signing_key: Option<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RefreshReport {
pub outcome: StepOutcome,
pub files: Vec<(String, StepOutcome)>,
pub opened: bool,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct TokenRef {
pub id: u64,
pub name: String,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct MintedToken {
pub token: TokenRef,
pub secret: Secret,
pub previous: Option<TokenRef>,
}
pub struct ForgejoForge {
config: ForgejoConfig,
api: Api,
token: RwLock<Arc<Secret>>,
current_token: RwLock<Option<TokenRef>>,
rotation: TokenRotation,
oauth_secret: Secret,
oauth_keys: OAuthKeys,
webhook_secret: Secret,
namespaces: RwLock<BTreeMap<Resource, Namespace>>,
probed: RwLock<Probed>,
}
impl std::fmt::Debug for ForgejoForge {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ForgejoForge")
.field("host", &self.config.host)
.field("bot", &self.config.bot_login)
.field("token", &"<redacted>")
.field("rotation", &self.rotation)
.field("oauth_secret", &self.oauth_secret)
.field("webhook_secret", &self.webhook_secret)
.finish_non_exhaustive()
}
}
impl ForgejoForge {
pub async fn connect(config: ForgejoConfig, credentials: Credentials) -> Result<Self> {
let Credentials {
bot_token,
rotation,
oauth_client_secret,
webhook_secret,
} = credentials;
for (what, s) in [
("bot token", &bot_token),
("OAuth client secret", &oauth_client_secret),
("webhook secret", &webhook_secret),
] {
if s.expose().is_empty() {
return Err(ForgeError::Config(format!("empty {what}")));
}
}
if config.oauth_client_id.is_empty() {
return Err(ForgeError::Config("empty OAuth client id".into()));
}
if let Some(context) = &config.status_check_context {
crate::plan::check_check_name(context)?;
}
check_login(&config.team_name)
.map_err(|_| ForgeError::Config(format!("bad team name `{}`", config.team_name)))?;
vgi_forge::Resource::namespace_of(&config.host, "x").map_err(|e| {
ForgeError::Config(format!("`{}` is not a forge host: {e}", config.host))
})?;
let api = Api::new(
config.api_base(),
config.base_url.clone(),
config.request_timeout,
)?;
let probed = probe(&api, &config, &bot_token).await?;
let oauth_keys = OAuthKeys::new(&oauth_client_secret);
Ok(ForgejoForge {
config,
api,
token: RwLock::new(Arc::new(bot_token)),
current_token: RwLock::new(None),
rotation,
oauth_secret: oauth_client_secret,
oauth_keys,
webhook_secret,
namespaces: RwLock::new(BTreeMap::new()),
probed: RwLock::new(probed),
})
}
pub fn config(&self) -> &ForgejoConfig {
&self.config
}
pub fn instance(&self) -> InstanceInfo {
self.probed().info
}
pub fn bot(&self) -> ForgeAccount {
self.probed().bot
}
pub async fn refresh(&self) -> Result<InstanceInfo> {
let token = self.token();
let probed = probe(&self.api, &self.config, &token).await?;
let info = probed.info.clone();
*self.probed.write().expect("probe lock poisoned") = probed;
Ok(info)
}
fn probed(&self) -> Probed {
self.probed.read().expect("probe lock poisoned").clone()
}
pub fn register_namespace(&self, ns: Namespace) -> Result<()> {
if ns.resource.host() != self.config.host || !ns.resource.is_namespace() {
return Err(ForgeError::WrongResource {
resource: ns.resource.to_string(),
expected: format!("a namespace on `{}`", self.config.host),
});
}
self.namespaces
.write()
.expect("namespace lock poisoned")
.insert(ns.resource.clone(), ns);
Ok(())
}
pub fn unregister_namespace(&self, ns: &Resource) {
self.namespaces
.write()
.expect("namespace lock poisoned")
.remove(ns);
}
pub fn new_state() -> Result<String> {
let mut bytes = [0u8; 32];
aws_lc_rs::rand::fill(&mut bytes)
.map_err(|_| ForgeError::Config("system RNG unavailable".into()))?;
Ok(URL_SAFE_NO_PAD.encode(bytes))
}
pub async fn fetch_signing_key(&self) -> Result<Vec<u8>> {
fetch_signing_key(&self.api, &self.token()).await
}
fn token(&self) -> Arc<Secret> {
self.token.read().expect("token lock poisoned").clone()
}
pub async fn replace_token(&self, new: Secret) -> Result<()> {
let bot = self.bot();
let who = whoami(&self.api, Auth::Token(&new)).await?;
if who.id != bot.id {
return Err(ForgeError::Config(format!(
"the new token belongs to `{}`, not the bot `{}`",
who.login, bot.login
)));
}
*self.token.write().expect("token lock poisoned") = Arc::new(new);
*self.current_token.write().expect("token lock poisoned") = None;
Ok(())
}
pub async fn mint_token(&self) -> Result<MintedToken> {
let password = self.bot_password()?;
let bot = self.bot();
let basic = Auth::Basic {
user: &bot.login,
password,
};
let tokens_url = self.api.url(&["users", &bot.login, "tokens"]);
let tracked = self
.current_token
.read()
.expect("token lock poisoned")
.clone();
let previous = match tracked {
Some(t) => Some(t),
None => {
let tail = last_eight(self.token().expose());
let listed: Vec<TokenInfoJson> = self
.api
.get_all(tokens_url.clone(), basic, "bot access tokens")
.await?;
let mut matching = listed
.into_iter()
.filter(|t| tail.is_some() && t.token_last_eight.as_deref() == tail.as_deref());
match (matching.next(), matching.next()) {
(Some(t), None) => Some(TokenRef {
id: t.id,
name: t.name,
}),
_ => None,
}
}
};
let mut suffix = [0u8; 4];
aws_lc_rs::rand::fill(&mut suffix)
.map_err(|_| ForgeError::Config("system RNG unavailable".into()))?;
let name = format!("{TOKEN_NAME_PREFIX}{}-{}", unix_now(), hex::encode(suffix));
let created: NewTokenJson = self
.api
.json_secret(
Method::POST,
tokens_url,
basic,
Some(&json!({ "name": name, "scopes": BOT_TOKEN_SCOPES })),
"bot access token",
)
.await?;
let minted = TokenRef {
id: created.id,
name: name.clone(),
};
let for_caller = Secret::new(created.sha1.clone());
let in_use = Secret::new(created.sha1.clone());
drop(created);
match whoami(&self.api, Auth::Token(&in_use)).await {
Ok(who) if who.id == bot.id => {}
other => {
let _ = self.delete_token(&bot.login, password, minted.id).await;
return Err(match other {
Ok(who) => ForgeError::Protocol(format!(
"the new token authenticates as `{}`, not the bot",
who.login
)),
Err(e) => e,
});
}
}
*self.token.write().expect("token lock poisoned") = Arc::new(in_use);
*self.current_token.write().expect("token lock poisoned") = Some(minted.clone());
Ok(MintedToken {
token: minted,
secret: for_caller,
previous,
})
}
pub async fn retire_token(&self, old: &TokenRef) -> Result<()> {
let password = self.bot_password()?;
if self
.current_token
.read()
.expect("token lock poisoned")
.as_ref()
.is_some_and(|t| t.id == old.id)
{
return Err(ForgeError::Config(format!(
"token `{}` is the one in use; mint a new one first",
old.name
)));
}
let bot = self.bot();
match self.delete_token(&bot.login, password, old.id).await {
Ok(()) | Err(ForgeError::NotFound { .. }) => Ok(()),
Err(e) => Err(e),
}
}
fn bot_password(&self) -> Result<&Secret> {
match &self.rotation {
TokenRotation::WithPassword(p) => Ok(p),
_ => Err(ForgeError::Unsupported {
operation: "bot token rotation".into(),
hint: format!(
"Forgejo mints and deletes tokens only under basic auth and this bridge \
holds no bot password: create a token for `{}` with scopes {}, pass it to \
`replace_token`, and delete the old one yourself",
self.config.bot_login,
BOT_TOKEN_SCOPES.join(", ")
),
}),
}
}
async fn delete_token(&self, login: &str, password: &Secret, id: u64) -> Result<()> {
let url = self.api.url(&["users", login, "tokens", &id.to_string()]);
self.api
.send(
Method::DELETE,
url,
Auth::Basic {
user: login,
password,
},
None,
"bot access token",
)
.await?;
Ok(())
}
fn namespace(&self, ns: &Resource) -> Result<Namespace> {
self.namespaces
.read()
.expect("namespace lock poisoned")
.get(ns)
.cloned()
.ok_or_else(|| ForgeError::NotBound {
namespace: ns.to_string(),
})
}
fn locate<'r>(&self, repo: &'r Resource) -> Result<(Namespace, &'r str, &'r str)> {
if repo.host() != self.config.host {
return Err(ForgeError::WrongResource {
resource: repo.to_string(),
expected: format!("a repository on `{}`", self.config.host),
});
}
repo.require_owner_repo()?;
let name = repo.repo_name().ok_or_else(|| ForgeError::WrongResource {
resource: repo.to_string(),
expected: "a repository (`<host>/<owner>/<repo>`), not a namespace".into(),
})?;
Ok((self.namespace(&repo.namespace())?, repo.owner(), name))
}
fn automated(&self, ns: &Namespace) -> Result<()> {
if ns.installation_id.is_none() {
return Err(ForgeError::Unsupported {
operation: "forge automation".into(),
hint: format!(
"namespace `{}` is in manual mode (no bot binding); run the steps by hand \
with `vgi repo init`",
ns.resource
),
});
}
Ok(())
}
fn repo_token<'r>(&self, repo: &'r Resource) -> Result<(Arc<Secret>, &'r str, &'r str)> {
let (ns, owner, name) = self.locate(repo)?;
self.automated(&ns)?;
Ok((self.token(), owner, name))
}
async fn get_repo(&self, token: &Secret, owner: &str, name: &str) -> Result<RepoJson> {
self.api
.json(
Method::GET,
self.api.url(&["repos", owner, name]),
Auth::Token(token),
None,
&format!("{}/{owner}/{name}", self.config.host),
)
.await
}
fn repo_state(&self, r: &RepoJson) -> Result<RepoState> {
let resource =
Resource::parse_owner_repo(&format!("{}/{}", self.config.host, r.full_name))?;
resource.require_owner_repo()?;
let mut state = RepoState::new(resource, r.id);
state.visibility = if r.private {
Visibility::Private
} else {
Visibility::Public
};
state.archived = r.archived;
state.default_branch = r.default_branch();
Ok(state)
}
async fn collaborators(
&self,
token: &Secret,
owner: &str,
name: &str,
) -> Result<Vec<(ForgeAccount, Perm)>> {
let users: Vec<UserJson> = self
.api
.get_all(
self.api.url(&["repos", owner, name, "collaborators"]),
Auth::Token(token),
"collaborators",
)
.await?;
let mut out = Vec::with_capacity(users.len());
for u in users {
check_login(&u.login)?;
let p: PermissionJson = self
.api
.json(
Method::GET,
self.api.url(&[
"repos",
owner,
name,
"collaborators",
&u.login,
"permission",
]),
Auth::Token(token),
None,
"collaborator permission",
)
.await?;
if let Some(perm) = Perm::parse(&p.permission) {
out.push((ForgeAccount::new(u.id, u.login), perm));
}
}
Ok(out)
}
async fn protection_rule(
&self,
token: &Secret,
owner: &str,
name: &str,
branch: &str,
) -> Result<(Option<ProtectionJson>, Vec<String>)> {
let mut rules: Vec<ProtectionJson> = self
.api
.json(
Method::GET,
self.api.url(&["repos", owner, name, "branch_protections"]),
Auth::Token(token),
None,
"branch protections",
)
.await?;
let (managed, shadowing) = select_rule(&rules, branch);
Ok((managed.map(|i| rules.swap_remove(i)), shadowing))
}
fn protection_state(
&self,
rule: Option<&ProtectionJson>,
shadowing: &[String],
repo: &RepoJson,
) -> ProtectionState {
let mut p = ProtectionState::default();
p.merge_methods = Some(repo.merge_methods());
p.ci_enabled = repo.has_actions;
let Some(rule) = rule else {
return p;
};
p.present = true;
p.enforced = true;
p.covers_default_branch = shadowing.is_empty();
p.requires_pull_request = !rule.enable_push;
if rule.enable_status_check {
p.required_checks = rule.status_check_contexts.clone();
}
p.blocks_force_push = rule.enable_force_push != Some(true);
p.blocks_deletion = true;
p.protected_paths = patterns(&rule.protected_file_patterns);
p.bypass_actors = rule.bypass_actors();
p.bypass_actors
.extend(shadowing.iter().map(|n| format!("shadowing-rule:{n}")));
p
}
fn allowed_merge_methods(&self) -> Vec<MergeMethod> {
let probed = self.probed();
if !probed.info.features.fast_forward_only
&& self.config.merge_fallback == MergeFallback::InstanceSigningKey
{
vec![MergeMethod::MergeCommit]
} else {
vec![MergeMethod::FastForward]
}
}
fn forgejo_gaps(&self, p: &ProtectionState) -> Vec<ProtectionGap> {
let mut gaps = Vec::new();
if p.present {
let missing: Vec<String> = PROTECTED_PATHS
.iter()
.filter(|want| !p.protected_paths.iter().any(|have| have == *want))
.map(|s| s.to_string())
.collect();
if !missing.is_empty() {
gaps.push(ProtectionGap::UnprotectedPaths { paths: missing });
}
}
let allowed = self.allowed_merge_methods();
if let Some(methods) = &p.merge_methods {
for m in methods {
if !allowed.contains(m) {
gaps.push(ProtectionGap::MergeMethodAllowed { method: *m });
}
}
}
if p.ci_enabled == Some(false) {
gaps.push(ProtectionGap::CiDisabled);
}
gaps
}
async fn write_file(
&self,
repo: &Resource,
path: &str,
contents: &[u8],
message: &str,
) -> Result<StepOutcome> {
validate_repo_path(path)?;
let (token, owner, name) = self.repo_token(repo)?;
let mut segments = vec!["repos", owner, name, "contents"];
segments.extend(path.split('/'));
let url = self.api.url(&segments);
let sha = match self.current_file(&token, owner, name, path).await? {
Some((_, current)) if current == contents => return Ok(StepOutcome::Unchanged),
Some((sha, _)) => Some(sha),
None => None,
};
let mut body = json!({ "message": message, "content": STANDARD.encode(contents) });
let method = match &sha {
Some(sha) => {
body["sha"] = json!(sha);
Method::PUT
}
None => Method::POST,
};
self.api
.send(method, url, Auth::Token(&token), Some(&body), path)
.await
.map_err(|e| match e {
ForgeError::Rejected { status, message } => ForgeError::Rejected {
status,
message: format!("{message}{PROTECTED_HINT}"),
},
ForgeError::Forbidden(message) => {
ForgeError::Forbidden(format!("{message}{PROTECTED_HINT}"))
}
e => e,
})?;
Ok(if sha.is_some() {
StepOutcome::Updated
} else {
StepOutcome::Created
})
}
async fn current_file(
&self,
token: &Secret,
owner: &str,
name: &str,
path: &str,
) -> Result<Option<(String, Vec<u8>)>> {
let mut segments = vec!["repos", owner, name, "contents"];
segments.extend(path.split('/'));
let existing: Option<Value> = self
.api
.get_opt(self.api.url(&segments), Auth::Token(token), path)
.await?;
match existing {
Some(Value::Array(_)) => Err(ForgeError::Rejected {
status: 409,
message: format!("`{path}` exists and is a directory, not a file"),
}),
Some(v) => {
let c: ContentJson = serde_json::from_value(v)
.map_err(|e| ForgeError::Protocol(format!("{path}: {e}")))?;
if c.kind != "file" {
return Err(ForgeError::Rejected {
status: 409,
message: format!("`{path}` exists and is a {}, not a file", c.kind),
});
}
let contents = decode_content(&c)?;
Ok(Some((c.sha, contents)))
}
None => Ok(None),
}
}
pub async fn refresh_managed_files(
&self,
repo: &Resource,
files: &[vgi_forge::ExtraFile],
message: &str,
) -> Result<RefreshReport> {
let (token, owner, name) = self.repo_token(repo)?;
let r = self.get_repo(&token, owner, name).await?;
let branch = r.default_branch().ok_or_else(|| ForgeError::Rejected {
status: 409,
message: format!("{repo} is empty: nothing to refresh"),
})?;
self.refresh_on_branch(repo, &branch, files, message).await
}
async fn refresh_on_branch(
&self,
repo: &Resource,
branch: &str,
files: &[vgi_forge::ExtraFile],
message: &str,
) -> Result<RefreshReport> {
for f in files {
validate_repo_path(&f.path)?;
}
let (token, owner, name) = self.repo_token(repo)?;
let mut stale = Vec::new();
for f in files {
let current = self.current_file(&token, owner, name, &f.path).await?;
if current.map(|(_, c)| c) != Some(f.contents.clone()) {
stale.push(f);
}
}
let mut report = RefreshReport {
outcome: StepOutcome::Unchanged,
files: files
.iter()
.map(|f| (f.path.clone(), StepOutcome::Unchanged))
.collect(),
opened: false,
detail: format!("{repo}: managed files already current"),
};
if stale.is_empty() {
return Ok(report);
}
let (rule, shadowing) = self.protection_rule(&token, owner, name, branch).await?;
if !shadowing.is_empty() {
return Err(ForgeError::Rejected {
status: 409,
message: format!(
"{repo}: rule(s) {} shadow the managed protection; resolve that first",
shadowing.join(", ")
),
});
}
let bot = self.bot();
let rule_url = |rule_name: &str| {
self.api
.url(&["repos", owner, name, "branch_protections", rule_name])
};
let prior = rule.as_ref().map(|r| {
(
r.name().unwrap_or(branch).to_string(),
json!({
"enable_push": r.enable_push,
"enable_push_whitelist": r.enable_push_whitelist,
"push_whitelist_usernames": r.push_whitelist_usernames,
"push_whitelist_teams": r.push_whitelist_teams,
"push_whitelist_deploy_keys": r.push_whitelist_deploy_keys,
"protected_file_patterns": r.protected_file_patterns,
}),
r.clone(),
)
});
let mut open_error = None;
if let Some((rule_name, _, _)) = &prior {
tracing::warn!(
repo = %repo,
bot = %bot.login,
files = ?stale.iter().map(|f| &f.path).collect::<Vec<_>>(),
"opening the default-branch protection to the bridge alone to refresh managed files"
);
let open = json!({
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": [bot.login],
"push_whitelist_teams": [],
"push_whitelist_deploy_keys": false,
"protected_file_patterns": "",
});
match self
.api
.send(
Method::PATCH,
rule_url(rule_name),
Auth::Token(&token),
Some(&open),
"branch protection (open for refresh)",
)
.await
{
Ok(_) => report.opened = true,
Err(e) => open_error = Some(e),
}
}
let mut write_error = None;
for (i, f) in files.iter().enumerate() {
if open_error.is_some() {
break;
}
if !stale.iter().any(|s| s.path == f.path) {
continue;
}
match self.write_file(repo, &f.path, &f.contents, message).await {
Ok(o) => report.files[i].1 = o,
Err(e) => {
write_error = Some((f.path.clone(), e));
break;
}
}
}
let mut restore_error = None;
if let Some((rule_name, body, before)) = &prior {
for _ in 0..2 {
let result: Result<ProtectionJson> = self
.api
.json(
Method::PATCH,
rule_url(rule_name),
Auth::Token(&token),
Some(body),
"branch protection (restore after refresh)",
)
.await;
restore_error = match result {
Ok(after) if same_push_settings(&after, before) => None,
Ok(_) => Some(ForgeError::Rejected {
status: 200,
message: "the restored protection does not read back as it was".into(),
}),
Err(e) => Some(e),
};
if restore_error.is_none() {
break;
}
}
}
let written: Vec<&str> = report
.files
.iter()
.filter(|(_, o)| *o != StepOutcome::Unchanged)
.map(|(p, _)| p.as_str())
.collect();
report.detail = format!(
"{repo}: protection {} for `{}`; wrote {:?}; {}",
match (&prior, &open_error) {
(None, _) => "absent, not opened".to_string(),
(Some(_), None) => "opened".to_string(),
(Some(_), Some(e)) => format!("open failed ({e})"),
},
bot.login,
written,
match (&restore_error, prior.is_some()) {
(None, true) => "protection restored and verified".to_string(),
(None, false) => "nothing to restore".to_string(),
(Some(e), _) => format!("PROTECTION LEFT OPEN: {e}"),
}
);
if let Some(e) = &restore_error {
tracing::error!(repo = %repo, error = %e, "refresh could not restore the protection");
return Err(ForgeError::Rejected {
status: 500,
message: report.detail,
});
}
if let Some(e) = open_error {
tracing::warn!(repo = %repo, detail = %report.detail, "refresh could not open the protection");
return Err(match e {
ForgeError::Rejected { status, message } => ForgeError::Rejected {
status,
message: format!("{message} (nothing written; protection restored)"),
},
other => other,
});
}
tracing::info!(repo = %repo, detail = %report.detail, "managed files refreshed");
if let Some((path, e)) = write_error {
return Err(match e {
ForgeError::Rejected { status, message } => ForgeError::Rejected {
status,
message: format!("{path}: {message} (protection restored)"),
},
other => other,
});
}
report.outcome = if written.is_empty() {
StepOutcome::Unchanged
} else {
StepOutcome::Updated
};
Ok(report)
}
async fn write_managed_file(
&self,
repo: &Resource,
path: &str,
contents: &[u8],
message: &str,
) -> Result<StepOutcome> {
let (token, owner, name) = self.repo_token(repo)?;
let Some(branch) = self.get_repo(&token, owner, name).await?.default_branch() else {
return self.write_file(repo, path, contents, message).await;
};
let file = vgi_forge::ExtraFile {
path: path.to_string(),
contents: contents.to_vec(),
};
let report = self
.refresh_on_branch(repo, &branch, std::slice::from_ref(&file), message)
.await?;
Ok(report
.files
.first()
.map_or(report.outcome, |(_, outcome)| *outcome))
}
pub fn refresh_plan(&self, repo: &RepoSpec, cfg: &VgiConfig) -> Result<Vec<BootstrapStep>> {
let files: Vec<vgi_forge::ExtraFile> = self
.bootstrap_plan(repo, cfg)?
.into_iter()
.filter_map(|s| match s.action {
StepAction::WriteFile { path, contents, .. }
if path == crate::plan::WORKFLOW_PATH || path == crate::plan::KEYRING_PATH =>
{
Some(vgi_forge::ExtraFile { path, contents })
}
_ => None,
})
.collect();
Ok(vec![BootstrapStep::new(
"refresh-managed-files",
vgi_forge::BootstrapComponent::Workflow,
StepAction::RefreshProtectedFiles {
files,
message: "ci: update the VGI commit-trust check".into(),
},
)])
}
async fn set_variable(&self, repo: &Resource, var: &str, value: &str) -> Result<StepOutcome> {
if var.is_empty()
|| !var
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
{
return Err(ForgeError::Config(format!(
"variable name `{var}` must be [A-Z0-9_]"
)));
}
if !self.probed().info.features.actions_variables {
return Err(ForgeError::Unsupported {
operation: "Actions variables".into(),
hint: "this instance has no variables API; the plan writes the DIDs into the \
workflow instead — rebuild the plan"
.into(),
});
}
let (token, owner, name) = self.repo_token(repo)?;
let url = self
.api
.url(&["repos", owner, name, "actions", "variables", var]);
match self
.api
.get_opt::<VariableJson>(url.clone(), Auth::Token(&token), var)
.await?
{
Some(v) if v.data == value => Ok(StepOutcome::Unchanged),
Some(_) => {
let body = json!({ "name": var, "value": value });
self.api
.send(Method::PUT, url, Auth::Token(&token), Some(&body), var)
.await?;
Ok(StepOutcome::Updated)
}
None => {
let body = json!({ "value": value });
self.api
.send(Method::POST, url, Auth::Token(&token), Some(&body), var)
.await?;
Ok(StepOutcome::Created)
}
}
}
async fn configure_repo(&self, repo: &Resource, s: &RepoSettings) -> Result<StepOutcome> {
let (token, owner, name) = self.repo_token(repo)?;
let r = self.get_repo(&token, owner, name).await?;
let ff_wanted = s.merge_methods.contains(&MergeMethod::FastForward);
let ff_available = self.probed().info.features.fast_forward_only
&& r.allow_fast_forward_only_merge.is_some();
if ff_wanted && !ff_available {
return Err(ForgeError::Unsupported {
operation: "fast-forward-only merges".into(),
hint: match self.config.merge_fallback {
MergeFallback::Fail => format!(
"`{}` ({}) cannot restrict merges to fast-forward only, and every web \
merge would land a commit the check never saw. Upgrade to Forgejo 7 \
or Gitea 1.22, or configure the signing-key merge fallback \
(the instance must sign merges)",
self.config.host,
self.probed().info.version
),
_ => "the plan was built for fast-forward-only merges but the instance \
does not offer them; rebuild the plan"
.into(),
},
});
}
if satisfies_settings(&r, s) {
return Ok(StepOutcome::Unchanged);
}
let body = settings_request(&r, s);
let after: RepoJson = self
.api
.json(
Method::PATCH,
self.api.url(&["repos", owner, name]),
Auth::Token(&token),
Some(&body),
repo.as_str(),
)
.await?;
if !satisfies_settings(&after, s) {
return Err(ForgeError::Rejected {
status: 200,
message: format!(
"{repo}: the instance accepted the settings but did not apply them all \
(are Actions or pull requests disabled instance-wide?)"
),
});
}
Ok(StepOutcome::Updated)
}
async fn protect(&self, repo: &Resource, spec: &ProtectionSpec) -> Result<StepOutcome> {
let (token, owner, name) = self.repo_token(repo)?;
let r = self.get_repo(&token, owner, name).await?;
let branch = r.default_branch().ok_or_else(|| ForgeError::Rejected {
status: 409,
message: format!("{repo} is empty: there is no default branch to protect yet"),
})?;
if is_glob(&branch) {
return Err(ForgeError::Unsupported {
operation: "protecting the default branch".into(),
hint: format!(
"the default branch `{branch}` contains glob characters, so Forgejo would \
read a rule for it as a pattern; rename the branch"
),
});
}
let (existing, shadowing) = self.protection_rule(&token, owner, name, &branch).await?;
if !shadowing.is_empty() {
return Err(ForgeError::Rejected {
status: 409,
message: format!(
"{repo}: branch protection rule(s) {} also match `{branch}` (Forgejo compares \
rule names case-insensitively and applies the oldest), so the managed rule \
may never apply; remove them and re-run",
shadowing.join(", ")
),
});
}
if let Some(rule) = &existing
&& satisfies_protection(rule, spec)
{
return Ok(StepOutcome::Unchanged);
}
let admins: Vec<String> = self
.collaborators(&token, owner, name)
.await?
.into_iter()
.filter(|(_, perm)| *perm == Perm::Admin)
.map(|(account, _)| account.login)
.collect();
let mut body = protection_request(existing.as_ref(), &admins, spec);
let (method, url, outcome) = match &existing {
Some(rule) => (
Method::PATCH,
self.api.url(&[
"repos",
owner,
name,
"branch_protections",
rule.name().unwrap_or(&branch),
]),
StepOutcome::Updated,
),
None => {
body["rule_name"] = json!(branch);
body["branch_name"] = json!(branch);
(
Method::POST,
self.api.url(&["repos", owner, name, "branch_protections"]),
StepOutcome::Created,
)
}
};
let after: ProtectionJson = self
.api
.json(
method,
url,
Auth::Token(&token),
Some(&body),
"branch protection",
)
.await?;
if !satisfies_protection(&after, spec) {
return Err(ForgeError::Rejected {
status: 200,
message: format!(
"{repo}: the instance accepted the branch protection but it does not read \
back as requested"
),
});
}
Ok(outcome)
}
fn expressible(&self, ns: &Namespace, desired: &[RoleAssignment]) -> Vec<RoleAssignment> {
desired
.iter()
.filter(|a| !is_personal_owner(ns, a.account.id))
.cloned()
.collect()
}
async fn login_for(&self, token: &Secret, id: u64) -> Result<String> {
let mut url = self.api.url(&["users", "search"]);
url.query_pairs_mut().append_pair("uid", &id.to_string());
let found: SearchJson = self
.api
.json(Method::GET, url, Auth::Token(token), None, "user")
.await?;
let user =
found
.data
.into_iter()
.find(|u| u.id == id)
.ok_or_else(|| ForgeError::NotFound {
what: format!("user {id}"),
})?;
check_login(&user.login)?;
Ok(user.login)
}
async fn access_sources(
&self,
token: &Secret,
owner: &str,
name: &str,
login: &str,
) -> Vec<AccessSource> {
let auth = Auth::Token(token);
let mut via = Vec::new();
let org: Option<OrgPermissionsJson> = self
.api
.get_opt(
self.api
.url(&["users", login, "orgs", owner, "permissions"]),
auth,
"organisation permissions",
)
.await
.ok()
.flatten();
if org.is_some_and(|o| o.is_owner) {
via.push(AccessSource::OrgOwner(owner.to_string()));
}
let teams: Vec<TeamJson> = self
.api
.get_all(
self.api.url(&["repos", owner, name, "teams"]),
auth,
"repository teams",
)
.await
.unwrap_or_default();
for t in teams {
let url = self
.api
.url(&["teams", &t.id.to_string(), "members", login]);
if self
.api
.exists(url, auth, "team member")
.await
.unwrap_or(false)
{
via.push(AccessSource::Team(t.name));
}
}
via
}
async fn set_collaborator(
&self,
token: &Secret,
owner: &str,
name: &str,
login: &str,
perm: Option<Perm>,
) -> Result<()> {
let url = self
.api
.url(&["repos", owner, name, "collaborators", login]);
match perm {
Some(p) => {
let body = json!({ "permission": p.as_str() });
self.api
.send(
Method::PUT,
url,
Auth::Token(token),
Some(&body),
"collaborator",
)
.await?;
}
None => {
self.api
.send(
Method::DELETE,
url,
Auth::Token(token),
None,
"collaborator",
)
.await?;
}
}
Ok(())
}
}
async fn probe(api: &Api, config: &ForgejoConfig, token: &Secret) -> Result<Probed> {
#[derive(Deserialize)]
struct Version {
version: String,
}
let v: Version = api
.json(
Method::GET,
api.url(&["version"]),
Auth::Token(token),
None,
"instance version",
)
.await?;
let info = InstanceInfo::from_version(&v.version);
let bot = whoami(api, Auth::Token(token)).await?;
if !bot.login.eq_ignore_ascii_case(&config.bot_login) {
return Err(ForgeError::Config(format!(
"the bot token belongs to `{}`, not the configured bot `{}`",
bot.login, config.bot_login
)));
}
let signing_key = if !info.features.fast_forward_only
&& config.merge_fallback == MergeFallback::InstanceSigningKey
{
Some(fetch_signing_key(api, token).await?)
} else {
None
};
Ok(Probed {
info,
bot,
signing_key,
})
}
async fn whoami(api: &Api, auth: Auth<'_>) -> Result<ForgeAccount> {
let u: UserJson = api
.json(
Method::GET,
api.url(&["user"]),
auth,
None,
"authenticated user",
)
.await?;
Ok(ForgeAccount::new(u.id, u.login))
}
async fn fetch_signing_key(api: &Api, token: &Secret) -> Result<Vec<u8>> {
let resp = api
.send(
Method::GET,
api.url(&["signing-key.gpg"]),
Auth::Token(token),
None,
"instance signing key",
)
.await?;
resp.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| ForgeError::Unavailable(e.without_url().to_string()))
}
const PROTECTED_HINT: &str = " — if the default branch is already protected, this file can only \
change through a pull request, and the workflow and keyring not \
even then (they are protected paths, by design): update those \
with the audited refresh-managed-files step";
#[async_trait]
impl Forge for ForgejoForge {
fn kind(&self) -> ForgeKind {
ForgeKind::Forgejo
}
fn host(&self) -> &str {
&self.config.host
}
fn capabilities(&self, ns: &Namespace) -> Capabilities {
let automated = ns.installation_id.is_some();
let mut c = Capabilities::default();
c.automation = automated;
c.required_checks = RequiredCheckKind::BranchProtection;
c.account_link = LinkMethod::AuthorizationCodePkce;
c.webhooks = false;
c.per_repo_tokens = false;
c.role_levels = LADDER.to_vec();
c.bot_can_create_repos = automated && ns.kind == NamespaceKind::Organization;
c
}
fn is_protected_account(&self, ns: &Namespace, account: u64) -> bool {
ns.owner_id == Some(account) || self.bot().id == account
}
async fn begin_bind(&self, req: BindRequest) -> Result<BindStep> {
if req.namespace.host() != self.config.host || !req.namespace.is_namespace() {
return Err(ForgeError::WrongResource {
resource: req.namespace.to_string(),
expected: format!("a namespace on `{}`", self.config.host),
});
}
if req.state.len() < MIN_STATE_LEN
|| !req
.state
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err(ForgeError::Config(format!(
"bind state must be at least {MIN_STATE_LEN} base64url characters from a CSPRNG \
(see ForgejoForge::new_state)"
)));
}
let verifier = self.oauth_keys.verifier(Purpose::Bind, &req.state);
Ok(BindStep::Redirect {
url: self
.authorize_url(&self.config.bind_redirect_uri, &req.state, &verifier)
.to_string(),
})
}
async fn complete_bind(&self, cb: BindCallback) -> Result<NamespaceBinding> {
let reject = |m: String| Err(ForgeError::BindRejected(m));
let state = cb.params.get("state").map(String::as_str).unwrap_or("");
if cb.expected_state.len() < MIN_STATE_LEN
|| aws_lc_rs::constant_time::verify_slices_are_equal(
state.as_bytes(),
cb.expected_state.as_bytes(),
)
.is_err()
{
return reject("the `state` does not match a bind this VTC started".into());
}
if let Some(err) = cb.params.get("error") {
return reject(format!(
"the admin did not authorise the bridge: {err} {}",
cb.params
.get("error_description")
.map(String::as_str)
.unwrap_or("")
));
}
let ns = &cb.expected_namespace;
if ns.host() != self.config.host || !ns.is_namespace() {
return reject(format!(
"`{ns}` is not a namespace on `{}`",
self.config.host
));
}
let owner = ns.owner();
let code = match cb.params.get("code") {
Some(c) if !c.is_empty() => c,
_ => return reject("missing authorisation `code`".into()),
};
let verifier = self.oauth_keys.verifier(Purpose::Bind, state);
let admin_token = self
.exchange_code(
code,
&self.config.bind_redirect_uri,
&verifier,
ForgeError::BindRejected,
)
.await?;
let result = self.bind_as_admin(ns, owner, &admin_token).await;
drop(admin_token);
result
}
async fn begin_account_link(&self, member: &str) -> Result<LinkStep> {
tracing::debug!(member, "starting Forgejo account link");
let state = self.oauth_keys.issue_link_state(member, unix_now())?;
let verifier = self.oauth_keys.verifier(Purpose::Link, &state);
Ok(LinkStep::Redirect {
url: self
.authorize_url(&self.config.link_redirect_uri, &state, &verifier)
.to_string(),
})
}
async fn complete_account_link(&self, cb: LinkCallback) -> Result<ForgeAccount> {
let LinkCallback::Redirect { params, member, .. } = cb else {
return Err(ForgeError::Unsupported {
operation: "device-flow account link".into(),
hint: "Forgejo has no device flow; members link through the browser \
(authorisation code + PKCE)"
.into(),
});
};
let state = params.get("state").map(String::as_str).unwrap_or("");
let member = member.ok_or_else(|| {
ForgeError::LinkFailed(
"the callback does not say which member started this link (build it with \
LinkCallback::redirect and the member from the caller's session)"
.into(),
)
})?;
self.oauth_keys
.check_link_state(state, &member, unix_now(), self.config.link_state_ttl)?;
if let Some(err) = params.get("error") {
return Err(ForgeError::LinkFailed(format!(
"the member did not authorise the bridge: {err}"
)));
}
let code = match params.get("code") {
Some(c) if !c.is_empty() => c,
_ => {
return Err(ForgeError::LinkFailed(
"missing authorisation `code`".into(),
));
}
};
let verifier = self.oauth_keys.verifier(Purpose::Link, state);
let token = self
.exchange_code(
code,
&self.config.link_redirect_uri,
&verifier,
ForgeError::LinkFailed,
)
.await?;
let account = whoami(&self.api, Auth::Bearer(&token)).await;
drop(token);
account
}
async fn inspect(&self, repo: &Resource) -> Result<RepoState> {
let (token, owner, name) = self.repo_token(repo)?;
let ns = self.namespace(&repo.namespace())?;
let r = self.get_repo(&token, owner, name).await?;
let mut state = self.repo_state(&r)?;
let (rule, shadowing) = match r.default_branch() {
Some(branch) => self.protection_rule(&token, owner, name, &branch).await?,
None => (None, Vec::new()),
};
let allow = rule
.as_ref()
.filter(|r| r.enable_merge_whitelist)
.map(|r| r.merge_whitelist_usernames.clone())
.unwrap_or_default();
for (account, perm) in self.collaborators(&token, owner, name).await? {
if is_personal_owner(&ns, account.id) {
continue;
}
let role = perm.observed(contains_login(&allow, &account.login));
state.collaborators.push(Collaborator::new(account, role));
}
state.protection = self.protection_state(rule.as_ref(), &shadowing, &r);
Ok(state)
}
async fn create_repo(&self, spec: &RepoSpec) -> Result<RepoState> {
let (ns, owner, name) = self.locate(&spec.resource)?;
if !self.capabilities(&ns).bot_can_create_repos {
return Err(ForgeError::Unsupported {
operation: "repository creation".into(),
hint: format!(
"the bridge cannot create repositories in `{}`; the account holder creates \
`{owner}/{name}`, adds `{}` as an admin collaborator, runs `vgi repo init`, \
and the repo is adopted",
ns.resource, self.config.bot_login
),
});
}
let private = match spec.visibility {
Visibility::Public => false,
Visibility::Private => true,
_ => {
return Err(ForgeError::Unsupported {
operation: "internal visibility".into(),
hint: "Forgejo repositories are public or private".into(),
});
}
};
let token = self.token();
if let Some(existing) = self
.api
.get_opt::<RepoJson>(
self.api.url(&["repos", owner, name]),
Auth::Token(&token),
spec.resource.as_str(),
)
.await?
{
return Err(ForgeError::AlreadyExists {
resource: spec.resource.to_string(),
forge_id: Some(existing.id),
});
}
let mut body = json!({
"name": name,
"private": private,
"auto_init": true,
"readme": "Default",
"default_branch": "main",
});
if let Some(d) = &spec.description {
body["description"] = json!(d);
}
let created: RepoJson = self
.api
.json(
Method::POST,
self.api.url(&["orgs", owner, "repos"]),
Auth::Token(&token),
Some(&body),
spec.resource.as_str(),
)
.await
.map_err(|e| match e {
ForgeError::Rejected { status: 409, .. } => ForgeError::AlreadyExists {
resource: spec.resource.to_string(),
forge_id: None,
},
e => e,
})?;
self.repo_state(&created)
}
async fn archive_repo(&self, repo: &Resource) -> Result<()> {
let (token, owner, name) = self.repo_token(repo)?;
let r = self.get_repo(&token, owner, name).await?;
if r.archived {
return Ok(());
}
self.api
.send(
Method::PATCH,
self.api.url(&["repos", owner, name]),
Auth::Token(&token),
Some(&json!({ "archived": true })),
repo.as_str(),
)
.await?;
Ok(())
}
async fn apply_roles(
&self,
repo: &Resource,
desired: &[RoleAssignment],
unlisted: Unlisted,
) -> Result<ApplyReport> {
let (ns, owner, name) = self.locate(repo)?;
self.automated(&ns)?;
let desired = self.expressible(&ns, desired);
let mut wanted: BTreeMap<u64, (ForgeAccount, ForgeRole)> = BTreeMap::new();
for a in &desired {
let role = collapse_to_ladder(a.role, &LADDER);
if let Some((_, prev)) = wanted.insert(a.account.id, (a.account.clone(), role))
&& prev != role
{
return Err(ForgeError::Config(format!(
"account {} is assigned two different roles",
a.account.id
)));
}
}
let token = self.token();
let r = self.get_repo(&token, owner, name).await?;
let rule = match r.default_branch() {
Some(branch) => self.protection_rule(&token, owner, name, &branch).await?.0,
None => None,
};
let allow: Vec<String> = rule
.as_ref()
.filter(|r| r.enable_merge_whitelist)
.map(|r| r.merge_whitelist_usernames.clone())
.unwrap_or_default();
let mut current: BTreeMap<u64, Have> = BTreeMap::new();
for (account, perm) in self.collaborators(&token, owner, name).await? {
if is_personal_owner(&ns, account.id) {
continue;
}
let listed = contains_login(&allow, &account.login);
current.insert(
account.id,
Have {
account,
perm,
listed,
},
);
}
let fatal = |e: &ForgeError| {
matches!(
e,
ForgeError::Unauthorized(_) | ForgeError::RateLimited { .. }
)
};
let mut report = ApplyReport::default();
let mut list_add: Vec<String> = Vec::new();
let mut list_drop: BTreeSet<u64> = BTreeSet::new();
let mut list_dependent: Vec<usize> = Vec::new();
let mut keep_listed: BTreeSet<u64> = BTreeSet::new();
let bot = self.bot().id;
for (id, (account, role)) in &wanted {
let have = current.get(id);
if *id == bot
&& *role == ForgeRole::None
&& let Some(h) = have
{
report.changes.push(RoleChange::new(
account.clone(),
h.perm.observed(h.listed),
ForgeRole::None,
RoleOutcome::Failed("the bridge's own bot is never removed".into()),
));
continue;
}
let need_perm = Perm::for_role(*role);
let need_listed = rule.is_some() && *role >= ForgeRole::Maintain;
let have_perm = have.map(|h| h.perm);
let have_listed = have.is_some_and(|h| h.listed);
let unexpressible = *role == ForgeRole::Maintain && rule.is_none();
if need_listed {
keep_listed.insert(*id);
}
if have_perm == need_perm && have_listed == need_listed && !unexpressible {
if *role != ForgeRole::None {
report.unchanged.push(account.clone());
}
continue;
}
let from = have.map_or(ForgeRole::None, |h| h.perm.observed(h.listed));
let mut outcome = RoleOutcome::Applied;
let mut fresh_login = None;
if have_perm != need_perm {
let result = match need_perm {
Some(p) => match self.login_for(&token, *id).await {
Ok(login) => {
let r = self
.set_collaborator(&token, owner, name, &login, Some(p))
.await;
fresh_login = Some(login);
r
}
Err(e) => Err(e),
},
None => {
let login = &have.expect("have_perm differs from None").account.login;
self.set_collaborator(&token, owner, name, login, None)
.await
}
};
if let Err(e) = result {
if fatal(&e) {
return Err(e);
}
report.changes.push(RoleChange::new(
account.clone(),
from,
*role,
RoleOutcome::Failed(e.to_string()),
));
continue;
}
}
if need_listed && !have_listed {
let login = match fresh_login {
Some(l) => Ok(l),
None => self.login_for(&token, *id).await,
};
match login {
Ok(l) => {
list_add.push(l);
list_dependent.push(report.changes.len());
}
Err(e) if fatal(&e) => return Err(e),
Err(e) => outcome = RoleOutcome::Failed(e.to_string()),
}
} else if !need_listed && have_listed {
list_drop.insert(*id);
list_dependent.push(report.changes.len());
}
if unexpressible {
outcome = RoleOutcome::Failed(
"granted `write`; `maintain` also needs a place on the default branch's merge \
allow-list, which exists once the repository is bootstrapped"
.into(),
);
}
report
.changes
.push(RoleChange::new(account.clone(), from, *role, outcome));
}
for (id, have) in ¤t {
if wanted.contains_key(id) {
continue;
}
let observed = have.perm.observed(have.listed);
match unlisted {
Unlisted::Remove => {
let outcome = match self
.set_collaborator(&token, owner, name, &have.account.login, None)
.await
{
Ok(()) => RoleOutcome::Applied,
Err(e) if fatal(&e) => return Err(e),
Err(e) => RoleOutcome::Failed(e.to_string()),
};
if have.listed {
list_drop.insert(*id);
}
report.changes.push(RoleChange::new(
have.account.clone(),
observed,
ForgeRole::None,
outcome,
));
}
_ => {
if have.listed {
keep_listed.insert(*id);
}
report
.kept_unlisted
.push(Collaborator::new(have.account.clone(), observed));
}
}
}
if let Some(rule) = &rule {
let id_of = |login: &str| {
current
.values()
.find(|h| h.account.login.eq_ignore_ascii_case(login))
.map(|h| h.account.id)
};
let mut next: Vec<String> = allow
.iter()
.filter(|login| match id_of(login) {
Some(id) => !list_drop.contains(&id) && keep_listed.contains(&id),
None => unlisted != Unlisted::Remove,
})
.cloned()
.collect();
for login in list_add {
if !contains_login(&next, &login) {
next.push(login);
}
}
let same = next.len() == allow.len()
&& next.iter().all(|l| contains_login(&allow, l))
&& rule.enable_merge_whitelist;
if !same {
let branch = rule.name().unwrap_or_default().to_string();
let body = json!({
"enable_merge_whitelist": true,
"merge_whitelist_usernames": next,
});
let result = self
.api
.send(
Method::PATCH,
self.api
.url(&["repos", owner, name, "branch_protections", &branch]),
Auth::Token(&token),
Some(&body),
"merge allow-list",
)
.await;
if let Err(e) = result {
if fatal(&e) {
return Err(e);
}
for i in list_dependent {
if let Some(c) = report.changes.get_mut(i)
&& c.outcome == RoleOutcome::Applied
{
c.outcome = RoleOutcome::Failed(format!("merge allow-list: {e}"));
}
}
}
}
}
Ok(report)
}
async fn indirect_access(
&self,
repo: &Resource,
account: &ForgeAccount,
) -> Result<Option<IndirectAccess>> {
let (ns, owner, name) = self.locate(repo)?;
self.automated(&ns)?;
let token = self.token();
let login = match self.login_for(&token, account.id).await {
Ok(l) => l,
Err(ForgeError::NotFound { .. }) => return Ok(None),
Err(e) => return Err(e),
};
let url = self
.api
.url(&["repos", owner, name, "collaborators", &login, "permission"]);
let Some(p) = self
.api
.get_opt::<PermissionJson>(url, Auth::Token(&token), "collaborator permission")
.await?
else {
return Ok(None);
};
let Some(perm) = Perm::parse(&p.permission) else {
return Ok(None);
};
if perm == Perm::Read && !self.get_repo(&token, owner, name).await?.private {
return Ok(None);
}
let via = if ns.kind == NamespaceKind::Organization {
self.access_sources(&token, owner, name, &login).await
} else {
Vec::new()
};
Ok(Some(IndirectAccess::new(perm.observed(false), via)))
}
fn bootstrap_plan(&self, repo: &RepoSpec, cfg: &VgiConfig) -> Result<Vec<BootstrapStep>> {
if repo.resource.host() != self.config.host {
return Err(ForgeError::WrongResource {
resource: repo.resource.to_string(),
expected: format!("a repository on `{}`", self.config.host),
});
}
repo.resource.require_owner_repo()?;
let probed = self.probed();
let key: Option<Vec<u8>> = if probed.info.features.fast_forward_only {
None
} else {
match self.config.merge_fallback {
MergeFallback::Fail => None,
_ => Some(
cfg.platform_keyring
.clone()
.or(probed.signing_key.clone())
.ok_or_else(|| {
ForgeError::Config(
"the signing-key merge fallback needs the instance's signing \
key; refresh the adapter or supply it as the platform keyring"
.into(),
)
})?,
),
}
};
let opts = PlanOptions {
checkout_action: &self.config.checkout_action,
actions_base: &self.config.actions_base,
runs_on: &self.config.runs_on,
status_context: self.config.status_context(&cfg.required_check),
inline_variables: !(self.config.use_actions_variables
&& probed.info.features.actions_variables),
merges: match &key {
Some(k) => MergePlan::SigningKey(k),
None => MergePlan::FastForwardOnly,
},
};
forgejo_plan(repo, cfg, &opts)
}
async fn run_step(&self, repo: &Resource, step: &BootstrapStep) -> Result<StepOutcome> {
match &step.action {
StepAction::WriteFile {
path,
contents,
message,
} if path == crate::plan::WORKFLOW_PATH || path == crate::plan::KEYRING_PATH => {
self.write_managed_file(repo, path, contents, message).await
}
StepAction::WriteFile {
path,
contents,
message,
} => self.write_file(repo, path, contents, message).await,
StepAction::SetVariable { name, value } => self.set_variable(repo, name, value).await,
StepAction::ProtectDefaultBranch(spec) => self.protect(repo, spec).await,
StepAction::ConfigureRepo(settings) => self.configure_repo(repo, settings).await,
StepAction::RefreshProtectedFiles { files, message } => self
.refresh_managed_files(repo, files, message)
.await
.map(|r| r.outcome),
other => Err(ForgeError::Unsupported {
operation: format!("bootstrap step {other:?}"),
hint: "this Forgejo adapter does not know that step".into(),
}),
}
}
fn parse_event(&self, headers: &HeaderMap, body: &[u8]) -> Result<Option<ForgeEvent>> {
webhook::parse(&self.webhook_secret, &self.config.host, headers, body)
}
fn diff(&self, observed: &RepoState, desired: &Projection) -> Vec<Drift> {
let mut want = desired.clone();
want.required_check = desired
.required_check
.as_deref()
.map(|c| self.config.status_context(c));
let mut drift = default_diff(observed, &want);
if desired.required_check.is_none()
|| drift.iter().any(|d| matches!(d, Drift::Replaced { .. }))
{
return drift;
}
let extra = self.forgejo_gaps(&observed.protection);
if extra.is_empty() {
return drift;
}
match drift
.iter_mut()
.find(|d| matches!(d, Drift::ProtectionWeakened { .. }))
{
Some(Drift::ProtectionWeakened { gaps }) => {
if !gaps.contains(&ProtectionGap::Missing) {
gaps.extend(extra);
} else {
gaps.extend(
extra
.into_iter()
.filter(|g| !matches!(g, ProtectionGap::UnprotectedPaths { .. })),
);
}
}
_ => drift.push(Drift::ProtectionWeakened { gaps: extra }),
}
drift
}
}
impl ForgejoForge {
fn authorize_url(&self, redirect: &url::Url, state: &str, verifier: &Secret) -> url::Url {
let mut url = self.api.web_url(&["login", "oauth", "authorize"]);
{
let mut q = url.query_pairs_mut();
q.append_pair("client_id", &self.config.oauth_client_id)
.append_pair("redirect_uri", redirect.as_str())
.append_pair("response_type", "code")
.append_pair("state", state)
.append_pair("code_challenge", &OAuthKeys::challenge(verifier))
.append_pair("code_challenge_method", "S256");
if let Some(scope) = &self.config.oauth_scope {
q.append_pair("scope", scope);
}
}
url
}
async fn exchange_code(
&self,
code: &str,
redirect: &url::Url,
verifier: &Secret,
fail: fn(String) -> ForgeError,
) -> Result<Secret> {
let url = self.api.web_url(&["login", "oauth", "access_token"]);
let t: TokenJson = self
.api
.oauth_token(
url,
&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect.as_str()),
("client_id", &self.config.oauth_client_id),
("client_secret", self.oauth_secret.expose()),
("code_verifier", verifier.expose()),
],
)
.await?;
t.into_token(fail)
}
async fn bind_as_admin(
&self,
ns: &Resource,
owner: &str,
admin_token: &Secret,
) -> Result<NamespaceBinding> {
let reject = |m: String| Err(ForgeError::BindRejected(m));
let admin_auth = Auth::Bearer(admin_token);
let admin = whoami(&self.api, admin_auth).await?;
let bot = self.bot();
if admin.id == bot.id {
return reject(
"the bot cannot bind a namespace: an owner must sign in as themselves".into(),
);
}
check_login(owner)?;
let org: Option<OrgJson> = self
.api
.get_opt(self.api.url(&["orgs", owner]), admin_auth, "organisation")
.await?;
let Some(org) = org else {
if !admin.login.eq_ignore_ascii_case(owner) {
return reject(format!(
"`{owner}` is not an organisation, and `{}` signed in — only the account \
holder can bind a personal namespace",
admin.login
));
}
let namespace = Namespace::new(ns.clone(), NamespaceKind::User)
.with_owner_id(admin.id)
.with_installation(bot.id);
return Ok(NamespaceBinding::new(namespace, Vec::new()));
};
let perms: OrgPermsJson = self
.api
.json(
Method::GET,
self.api
.url(&["users", &admin.login, "orgs", owner, "permissions"]),
admin_auth,
None,
"organisation permissions",
)
.await?;
if !perms.is_owner {
return reject(format!(
"`{}` is not an owner of `{owner}`; an owner must bind the namespace",
admin.login
));
}
let team = self.ensure_team(admin_token, owner).await?;
let member = self
.api
.url(&["teams", &team.id.to_string(), "members", &bot.login]);
if !self
.api
.exists(member.clone(), admin_auth, "team member")
.await?
{
self.api
.send(Method::PUT, member, admin_auth, None, "team member")
.await?;
}
let mut missing = Vec::new();
let bot_perms: OrgPermsJson = self
.api
.json(
Method::GET,
self.api
.url(&["users", &bot.login, "orgs", owner, "permissions"]),
Auth::Token(&self.token()),
None,
"bot organisation permissions",
)
.await?;
if !bot_perms.can_create_repository {
missing.push(format!(
"create repositories in `{owner}` (team `{}`)",
self.config.team_name
));
}
if let Some(hook_url) = &self.config.webhook_url {
match self.ensure_hook(admin_token, owner, hook_url).await {
Ok(()) => {}
Err(ForgeError::Forbidden(m) | ForgeError::Rejected { message: m, .. }) => {
missing.push(format!("org webhook: {m}"));
}
Err(ForgeError::NotFound { .. }) => {
missing.push("org webhook: webhooks are disabled on the instance".into());
}
Err(e) => return Err(e),
}
}
let namespace = Namespace::new(ns.clone(), NamespaceKind::Organization)
.with_owner_id(org.id)
.with_installation(team.id);
Ok(NamespaceBinding::new(namespace, missing))
}
async fn ensure_team(&self, admin_token: &Secret, org: &str) -> Result<TeamJson> {
let auth = Auth::Bearer(admin_token);
let teams: Vec<TeamJson> = self
.api
.get_all(self.api.url(&["orgs", org, "teams"]), auth, "teams")
.await?;
let body = json!({
"name": self.config.team_name,
"description": "VGI bridge bot: creates repositories and enforces the VTC's roles \
and commit-trust protection. Managed by the bridge.",
"permission": "admin",
"can_create_org_repo": true,
"includes_all_repositories": true,
"units": TEAM_UNITS,
});
let existing = teams
.into_iter()
.find(|t| t.name.eq_ignore_ascii_case(&self.config.team_name));
if let Some(t) = &existing {
let bot = self.bot();
let members: Vec<UserJson> = self
.api
.get_all(
self.api.url(&["teams", &t.id.to_string(), "members"]),
auth,
"team members",
)
.await?;
let others: Vec<String> = members
.into_iter()
.filter(|m| m.id != bot.id)
.map(|m| m.login)
.collect();
if !others.is_empty() {
return Err(ForgeError::BindRejected(format!(
"`{org}` already has a team named `{}` with other members ({}); the bridge \
will not adopt it and grant them admin on every repository. Rename that \
team or configure another team name",
t.name,
others.join(", ")
)));
}
}
match existing {
Some(t)
if t.permission == "admin"
&& t.can_create_org_repo
&& t.includes_all_repositories =>
{
Ok(t)
}
Some(t) => {
self.api
.json(
Method::PATCH,
self.api.url(&["teams", &t.id.to_string()]),
auth,
Some(&body),
"team",
)
.await
}
None => {
self.api
.json(
Method::POST,
self.api.url(&["orgs", org, "teams"]),
auth,
Some(&body),
"team",
)
.await
}
}
}
async fn ensure_hook(&self, admin_token: &Secret, org: &str, url: &url::Url) -> Result<()> {
let auth = Auth::Bearer(admin_token);
let hooks: Vec<HookJson> = self
.api
.get_all(self.api.url(&["orgs", org, "hooks"]), auth, "org webhooks")
.await?;
let config = json!({
"url": url.as_str(),
"content_type": "json",
"secret": self.webhook_secret.expose(),
});
let existing = hooks.into_iter().find(|h| {
h.config.get("url").map(String::as_str) == Some(url.as_str())
|| h.url.as_deref() == Some(url.as_str())
});
match existing {
Some(h) => {
let body = json!({ "config": config, "events": HOOK_EVENTS, "active": true });
self.api
.send(
Method::PATCH,
self.api.url(&["orgs", org, "hooks", &h.id.to_string()]),
auth,
Some(&body),
"org webhook",
)
.await?;
}
None => {
let kind = if self.probed().info.features.forgejo_webhooks {
"forgejo"
} else {
"gitea"
};
let body = json!({
"type": kind,
"config": config,
"events": HOOK_EVENTS,
"active": true,
});
self.api
.send(
Method::POST,
self.api.url(&["orgs", org, "hooks"]),
auth,
Some(&body),
"org webhook",
)
.await?;
}
}
Ok(())
}
}
impl ForgeHooks for ForgejoForge {
fn before_apply_roles(
&self,
repo: &Resource,
desired: &[RoleAssignment],
) -> HookDecision<Vec<RoleAssignment>> {
let Ok(ns) = self.namespace(&repo.namespace()) else {
return HookDecision::Continue;
};
let kept = self.expressible(&ns, desired);
if kept.len() == desired.len() {
HookDecision::Continue
} else {
HookDecision::Modify(kept)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Perm {
Read,
Write,
Admin,
}
impl Perm {
fn parse(s: &str) -> Option<Perm> {
match s {
"read" => Some(Perm::Read),
"write" => Some(Perm::Write),
"admin" | "owner" => Some(Perm::Admin),
_ => None,
}
}
fn as_str(self) -> &'static str {
match self {
Perm::Read => "read",
Perm::Write => "write",
Perm::Admin => "admin",
}
}
fn for_role(role: ForgeRole) -> Option<Perm> {
match role {
ForgeRole::Admin => Some(Perm::Admin),
ForgeRole::Maintain | ForgeRole::Write => Some(Perm::Write),
ForgeRole::None => None,
_ => Some(Perm::Read),
}
}
fn observed(self, listed: bool) -> ForgeRole {
match self {
Perm::Admin => ForgeRole::Admin,
Perm::Write if listed => ForgeRole::Maintain,
Perm::Write => ForgeRole::Write,
Perm::Read => ForgeRole::Read,
}
}
}
struct Have {
account: ForgeAccount,
perm: Perm,
listed: bool,
}
fn is_personal_owner(ns: &Namespace, id: u64) -> bool {
ns.kind == NamespaceKind::User && ns.owner_id == Some(id)
}
pub(crate) fn is_glob(name: &str) -> bool {
name.contains(['*', '?', '[', ']', '{', '}', '\\'])
}
fn contains_login(list: &[String], login: &str) -> bool {
list.iter().any(|l| l.eq_ignore_ascii_case(login))
}
fn patterns(s: &str) -> Vec<String> {
s.split(';')
.map(|p| p.trim().to_ascii_lowercase())
.filter(|p| !p.is_empty())
.collect()
}
fn last_eight(token: &str) -> Option<String> {
(token.len() >= 8).then(|| token[token.len() - 8..].to_string())
}
fn merge_style(m: MergeMethod) -> &'static str {
match m {
MergeMethod::FastForward => "fast-forward-only",
MergeMethod::Rebase => "rebase",
MergeMethod::RebaseMerge => "rebase-merge",
MergeMethod::Squash => "squash",
_ => "merge",
}
}
fn select_rule(rules: &[ProtectionJson], branch: &str) -> (Option<usize>, Vec<String>) {
let folded = branch.to_lowercase();
let mut managed = None;
let mut shadowing = Vec::new();
for (i, rule) in rules.iter().enumerate() {
match rule.name() {
Some(n) if n == branch => managed = Some(i),
Some(n) if !is_glob(n) && n.to_lowercase() == folded => shadowing.push(n.to_string()),
_ => {}
}
}
(managed, shadowing)
}
fn settings_request(r: &RepoJson, s: &RepoSettings) -> Value {
let has = |m| s.merge_methods.contains(&m);
let mut body = json!({});
if !s.merge_methods.is_empty() {
body = json!({
"has_pull_requests": true,
"allow_merge_commits": has(MergeMethod::MergeCommit),
"allow_rebase": has(MergeMethod::Rebase),
"allow_rebase_explicit": has(MergeMethod::RebaseMerge),
"allow_squash_merge": has(MergeMethod::Squash),
"default_merge_style": merge_style(s.merge_methods[0]),
});
if r.allow_fast_forward_only_merge.is_some() {
body["allow_fast_forward_only_merge"] = json!(has(MergeMethod::FastForward));
}
}
if s.enable_ci {
body["has_actions"] = json!(true);
}
body
}
fn protection_request(
existing: Option<&ProtectionJson>,
admins: &[String],
spec: &ProtectionSpec,
) -> Value {
let mut allow: Vec<String> = existing
.filter(|r| r.enable_merge_whitelist)
.map(|r| r.merge_whitelist_usernames.clone())
.unwrap_or_default();
for login in admins {
if !contains_login(&allow, login) {
allow.push(login.clone());
}
}
let mut contexts = existing
.map(|r| r.status_check_contexts.clone())
.unwrap_or_default();
if !contexts.contains(&spec.required_check) {
contexts.push(spec.required_check.clone());
}
let mut paths = existing
.map(|r| patterns(&r.protected_file_patterns))
.unwrap_or_default();
for p in &spec.protected_paths {
let p = p.to_ascii_lowercase();
if !paths.contains(&p) {
paths.push(p);
}
}
json!({
"enable_push": !spec.require_pull_request,
"enable_push_whitelist": false,
"push_whitelist_usernames": [],
"push_whitelist_teams": [],
"push_whitelist_deploy_keys": false,
"enable_merge_whitelist": true,
"merge_whitelist_usernames": allow,
"merge_whitelist_teams": [],
"enable_status_check": true,
"status_check_contexts": contexts,
"protected_file_patterns": paths.join(";"),
"unprotected_file_patterns": "",
"apply_to_admins": true,
})
}
fn parse<T: serde::de::DeserializeOwned>(what: &str, v: &Value) -> Result<T> {
serde_json::from_value(v.clone()).map_err(|e| ForgeError::Protocol(format!("{what}: {e}")))
}
pub fn managed_protection_rule(
rules: &Value,
branch: &str,
) -> Result<(Option<Value>, Vec<String>)> {
let mut list: Vec<Value> = parse("branch protections", rules)?;
let typed = list
.iter()
.map(|v| parse::<ProtectionJson>("branch protection", v))
.collect::<Result<Vec<_>>>()?;
let (managed, shadowing) = select_rule(&typed, branch);
Ok((managed.map(|i| list.swap_remove(i)), shadowing))
}
pub fn protection_satisfies(rule: &Value, spec: &ProtectionSpec) -> Result<bool> {
Ok(satisfies_protection(
&parse("branch protection", rule)?,
spec,
))
}
pub fn protection_body(
existing: Option<&Value>,
admins: &[String],
spec: &ProtectionSpec,
) -> Result<Value> {
let existing: Option<ProtectionJson> = existing
.map(|v| parse("branch protection", v))
.transpose()?;
Ok(protection_request(existing.as_ref(), admins, spec))
}
pub fn settings_satisfied(repo: &Value, s: &RepoSettings) -> Result<bool> {
Ok(satisfies_settings(&parse("repository", repo)?, s))
}
pub fn settings_body(repo: &Value, s: &RepoSettings) -> Result<Value> {
Ok(settings_request(&parse("repository", repo)?, s))
}
fn satisfies_settings(r: &RepoJson, s: &RepoSettings) -> bool {
if s.enable_ci && r.has_actions != Some(true) {
return false;
}
if s.merge_methods.is_empty() {
return true;
}
r.has_pull_requests != Some(false)
&& r.merge_methods() == {
let mut want = s.merge_methods.clone();
want.sort();
want.dedup();
want
}
&& r.default_merge_style.as_deref() == Some(merge_style(s.merge_methods[0]))
}
fn same_push_settings(a: &ProtectionJson, b: &ProtectionJson) -> bool {
let set = |v: &[String]| {
let mut v: Vec<String> = v.iter().map(|s| s.to_lowercase()).collect();
v.sort();
v
};
a.enable_push == b.enable_push
&& (!a.enable_push
|| (a.enable_push_whitelist == b.enable_push_whitelist
&& set(&a.push_whitelist_usernames) == set(&b.push_whitelist_usernames)
&& set(&a.push_whitelist_teams) == set(&b.push_whitelist_teams)
&& a.push_whitelist_deploy_keys == b.push_whitelist_deploy_keys))
&& patterns(&a.protected_file_patterns) == patterns(&b.protected_file_patterns)
}
fn satisfies_protection(rule: &ProtectionJson, spec: &ProtectionSpec) -> bool {
let paths = patterns(&rule.protected_file_patterns);
(!spec.require_pull_request || !rule.enable_push)
&& rule.enable_status_check
&& rule.status_check_contexts.contains(&spec.required_check)
&& rule.enable_merge_whitelist
&& rule.merge_whitelist_teams.is_empty()
&& patterns(&rule.unprotected_file_patterns).is_empty()
&& rule.apply_to_admins != Some(false)
&& rule.enable_force_push != Some(true)
&& spec
.protected_paths
.iter()
.all(|p| paths.contains(&p.to_ascii_lowercase()))
}
fn decode_content(c: &ContentJson) -> Result<Vec<u8>> {
match c.encoding.as_deref() {
Some("base64") => {
let compact: String = c
.content
.as_deref()
.unwrap_or("")
.chars()
.filter(|ch| !ch.is_whitespace())
.collect();
STANDARD
.decode(compact)
.map_err(|e| ForgeError::Protocol(format!("file content: {e}")))
}
None => Err(ForgeError::Rejected {
status: 409,
message: "the existing file is too large for the instance to return inline; it was \
not written by the bootstrap — remove or rename it"
.into(),
}),
other => Err(ForgeError::Protocol(format!(
"file content in unknown encoding {other:?}"
))),
}
}
fn nullable<'de, D, T>(d: D) -> std::result::Result<T, D::Error>
where
D: Deserializer<'de>,
T: Default + Deserialize<'de>,
{
Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
}
#[derive(Deserialize)]
struct RepoJson {
id: u64,
full_name: String,
#[serde(default)]
private: bool,
#[serde(default)]
archived: bool,
#[serde(default)]
empty: bool,
#[serde(default)]
default_branch: Option<String>,
#[serde(default)]
has_pull_requests: Option<bool>,
#[serde(default)]
has_actions: Option<bool>,
#[serde(default)]
allow_fast_forward_only_merge: Option<bool>,
#[serde(default)]
allow_merge_commits: Option<bool>,
#[serde(default)]
allow_rebase: Option<bool>,
#[serde(default)]
allow_rebase_explicit: Option<bool>,
#[serde(default)]
allow_squash_merge: Option<bool>,
#[serde(default)]
default_merge_style: Option<String>,
}
impl RepoJson {
fn default_branch(&self) -> Option<String> {
self.default_branch
.clone()
.filter(|b| !b.is_empty() && !self.empty)
}
fn merge_methods(&self) -> Vec<MergeMethod> {
if self.has_pull_requests == Some(false) {
return Vec::new();
}
let mut m: Vec<MergeMethod> = [
(self.allow_fast_forward_only_merge, MergeMethod::FastForward),
(self.allow_merge_commits, MergeMethod::MergeCommit),
(self.allow_rebase, MergeMethod::Rebase),
(self.allow_rebase_explicit, MergeMethod::RebaseMerge),
(self.allow_squash_merge, MergeMethod::Squash),
]
.into_iter()
.filter(|(on, _)| *on == Some(true))
.map(|(_, m)| m)
.collect();
m.sort();
m
}
}
#[derive(Deserialize)]
struct UserJson {
id: u64,
login: String,
}
#[derive(Deserialize)]
struct SearchJson {
#[serde(default, deserialize_with = "nullable")]
data: Vec<UserJson>,
}
#[derive(Deserialize)]
struct PermissionJson {
permission: String,
}
#[derive(Deserialize)]
struct OrgPermissionsJson {
#[serde(default)]
is_owner: bool,
}
#[derive(Deserialize)]
struct OrgJson {
id: u64,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct OrgPermsJson {
is_owner: bool,
can_create_repository: bool,
}
#[derive(Deserialize)]
struct TeamJson {
id: u64,
name: String,
#[serde(default)]
permission: String,
#[serde(default)]
can_create_org_repo: bool,
#[serde(default)]
includes_all_repositories: bool,
}
#[derive(Deserialize)]
struct HookJson {
id: u64,
#[serde(default)]
url: Option<String>,
#[serde(default, deserialize_with = "nullable")]
config: BTreeMap<String, String>,
}
#[derive(Deserialize)]
struct VariableJson {
#[serde(default)]
data: String,
}
#[derive(Deserialize)]
struct ContentJson {
#[serde(default)]
sha: String,
#[serde(rename = "type")]
kind: String,
#[serde(default)]
content: Option<String>,
#[serde(default)]
encoding: Option<String>,
}
#[derive(Deserialize)]
struct NewTokenJson {
id: u64,
sha1: String,
}
impl Drop for NewTokenJson {
fn drop(&mut self) {
use zeroize::Zeroize;
self.sha1.zeroize();
}
}
#[derive(Deserialize)]
struct TokenInfoJson {
id: u64,
name: String,
#[serde(default)]
token_last_eight: Option<String>,
}
#[derive(Deserialize, Default, Clone)]
#[serde(default)]
struct ProtectionJson {
rule_name: Option<String>,
branch_name: Option<String>,
enable_push: bool,
enable_push_whitelist: bool,
#[serde(deserialize_with = "nullable")]
push_whitelist_usernames: Vec<String>,
#[serde(deserialize_with = "nullable")]
push_whitelist_teams: Vec<String>,
push_whitelist_deploy_keys: bool,
enable_merge_whitelist: bool,
#[serde(deserialize_with = "nullable")]
merge_whitelist_usernames: Vec<String>,
#[serde(deserialize_with = "nullable")]
merge_whitelist_teams: Vec<String>,
enable_status_check: bool,
#[serde(deserialize_with = "nullable")]
status_check_contexts: Vec<String>,
#[serde(deserialize_with = "nullable")]
protected_file_patterns: String,
#[serde(deserialize_with = "nullable")]
unprotected_file_patterns: String,
apply_to_admins: Option<bool>,
enable_force_push: Option<bool>,
}
impl ProtectionJson {
fn name(&self) -> Option<&str> {
self.rule_name
.as_deref()
.filter(|n| !n.is_empty())
.or(self.branch_name.as_deref())
}
fn bypass_actors(&self) -> Vec<String> {
let mut out = Vec::new();
if self.apply_to_admins != Some(true) {
out.push("repository admins (the rule does not apply to admins)".into());
}
if self.enable_push {
if !self.enable_push_whitelist {
out.push("push: everyone with write access".into());
} else {
out.extend(
self.push_whitelist_usernames
.iter()
.map(|u| format!("push:{u}")),
);
out.extend(
self.push_whitelist_teams
.iter()
.map(|t| format!("push-team:{t}")),
);
if self.push_whitelist_deploy_keys {
out.push("push:deploy-keys".into());
}
}
}
let unprotected = patterns(&self.unprotected_file_patterns);
if !unprotected.is_empty() {
out.push(format!("unprotected-files:{}", unprotected.join(";")));
}
if !self.enable_merge_whitelist {
out.push("merge: everyone with write access".into());
}
out.extend(
self.merge_whitelist_teams
.iter()
.map(|t| format!("merge-team:{t}")),
);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roles_map_both_ways() {
for role in LADDER {
let perm = Perm::for_role(role).unwrap();
assert_eq!(perm.observed(role >= ForgeRole::Maintain), role);
assert_eq!(Perm::parse(perm.as_str()), Some(perm));
}
assert_eq!(Perm::for_role(ForgeRole::None), None);
assert_eq!(Perm::parse("owner"), Some(Perm::Admin));
assert_eq!(Perm::parse("none"), None);
assert_eq!(
collapse_to_ladder(ForgeRole::Triage, &LADDER),
ForgeRole::Read
);
}
#[test]
fn patterns_are_read_as_forgejo_compiles_them() {
assert_eq!(
patterns(" .Forgejo/workflows/** ;;x.asc; "),
[".forgejo/workflows/**", "x.asc"]
);
assert!(patterns("").is_empty());
}
#[test]
fn null_lists_deserialise_as_empty() {
let p: ProtectionJson = serde_json::from_value(json!({
"rule_name": "main",
"merge_whitelist_usernames": null,
"status_check_contexts": null,
"protected_file_patterns": null,
}))
.unwrap();
assert!(p.merge_whitelist_usernames.is_empty() && p.status_check_contexts.is_empty());
assert_eq!(p.apply_to_admins, None);
assert_eq!(
p.bypass_actors(),
[
"repository admins (the rule does not apply to admins)",
"merge: everyone with write access",
]
);
}
#[test]
fn glob_characters_are_forgejos() {
for g in ["main*", "rel?", "[ab]", "{a,b}", "a\\b"] {
assert!(is_glob(g), "{g}");
}
for plain in ["main", "release/1.0", "feature-x_y", "Verify commit trust"] {
assert!(!is_glob(plain), "{plain}");
}
}
}