//! Read-only forge catalogs; fetch targets always stay on the configured remote. use crate::repository::{checked, run}; use serde_json::Value; use std::{ collections::BTreeMap, path::Path, process::Command, sync::atomic::{AtomicBool, Ordering}, }; pub(crate) struct PullRequestSummary { pub number: u64, pub title: String, pub description: String, pub closed: bool, pub created_at: String, } pub(crate) struct PullRequestPage { pub entries: Vec, pub has_more: bool, } pub(crate) struct PullRequest { pub revisions: Result, } pub(crate) struct PullRequestRevisions { pub base_ref: String, pub base_sha: String, pub head_ref: String, pub head_sha: String, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Provider { Github, Gitlab, Gitea, } struct Forge { provider: Provider, host: String, endpoint: String, } fn encode(text: &str) -> String { use std::fmt::Write; let mut encoded = String::new(); for byte in text.bytes() { if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) { encoded.push(byte as char); } else { write!(encoded, "%{byte:02X}").unwrap(); } } encoded } fn decode(path: &str) -> Result { let mut bytes = path.bytes(); let mut decoded = Vec::with_capacity(path.len()); while let Some(byte) = bytes.next() { decoded.push(if byte == b'%' { let hi = bytes.next().and_then(|b| (b as char).to_digit(16)); let lo = bytes.next().and_then(|b| (b as char).to_digit(16)); let (hi, lo) = hi.zip(lo).ok_or("Invalid escaped remote path")?; (hi * 16 + lo) as u8 } else { byte }); } String::from_utf8(decoded).map_err(|_| "Remote path is not UTF-8".into()) } fn ssh_hostname( mut command: Command, destination: &str, port: &str, cancelled: &AtomicBool, ) -> Result { command.arg("-G"); if !port.is_empty() { command.args(["-p", &port[1..]]); } command.args(["--", destination]); let output = checked(run(command, vec![], cancelled)?) .map_err(|error| format!("Cannot resolve SSH remote configuration: {error}"))?; let output = std::str::from_utf8(&output).map_err(|_| "SSH configuration is not UTF-8")?; output .lines() .find_map(|line| line.strip_prefix("hostname ")) .map(str::to_owned) .ok_or_else(|| "SSH configuration returned no hostname".into()) } impl Forge { fn parse( url: &str, provider: Option<&str>, resolve_ssh: impl FnOnce(&str, &str) -> Result, ) -> Result { if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains(['?', '#', '\\']) { return Err("Invalid forge remote URL".into()); } let (authority, path, ssh) = if let Some((scheme, rest)) = url.split_once("://") { if !matches!(scheme, "https" | "ssh" | "git+ssh") { return Err("Pull requests require an HTTPS or SSH remote".into()); } let (authority, path) = rest .split_once('/') .ok_or("Forge remote has no repository path")?; (authority, path, scheme != "https") } else { let separator = if let Some(end) = url.find(']') { end + 1 } else { url.find(':') .ok_or("Pull requests require an HTTPS or SSH remote")? }; if url.as_bytes().get(separator) != Some(&b':') { return Err("Invalid SSH remote".into()); } (&url[..separator], &url[separator + 1..], true) }; let destination = authority; // Git URL userinfo must never become an API credential. if ssh && authority .rsplit_once('@') .is_some_and(|(user, _)| user.contains(':')) { return Err("SSH remote URLs must not contain passwords".into()); } let authority = authority.rsplit('@').next().unwrap(); let (host, port) = if authority.starts_with('[') { let end = authority.find(']').ok_or("Invalid remote host")?; authority[1..end] .parse::() .map_err(|_| "Invalid remote host")?; (&authority[..=end], &authority[end + 1..]) } else { let (host, port) = authority.split_at(authority.find(':').unwrap_or(authority.len())); if host.is_empty() || host.starts_with('-') || !host .bytes() .all(|b| b.is_ascii_alphanumeric() || b".-".contains(&b)) { return Err("Invalid remote host".into()); } (host, port) }; if !port.is_empty() && port .strip_prefix(':') .and_then(|p| p.parse::().ok()) .filter(|p| *p != 0) .is_none() { return Err("Invalid remote port".into()); } let mut host = host.to_ascii_lowercase(); if ssh { host = resolve_ssh(&destination[..destination.len() - port.len()], port)? .to_ascii_lowercase(); let ipv6 = host.trim_start_matches('[').trim_end_matches(']'); if ipv6.parse::().is_ok() { host = format!("[{ipv6}]"); } else if host.is_empty() || host.starts_with('-') || !host .bytes() .all(|b| b.is_ascii_alphanumeric() || b".-".contains(&b)) { return Err("SSH configuration returned an invalid hostname".into()); } } if ssh && host == "ssh.github.com" { host = "github.com".into(); } if ssh && host == "altssh.gitlab.com" { host = "gitlab.com".into(); } let provider = match provider { Some("github") => Provider::Github, Some("gitlab") => Provider::Gitlab, Some("gitea") => Provider::Gitea, Some(_) => { return Err("remote..vamp-forge must be github, gitlab, or gitea".into()); } None if host == "github.com" => Provider::Github, None if host.split('.').any(|label| label == "gitlab") => Provider::Gitlab, None if host == "codeberg.org" || host.split('.').any(|label| label == "gitea") => { Provider::Gitea } None => { return Err(format!( "Unknown forge for {host}; run `git config remote..vamp-forge github` (or use gitlab or gitea)" )); } }; let path = decode(path.trim_matches('/'))?; let path = path.strip_suffix(".git").unwrap_or(&path); let parts: Vec<_> = path.split('/').collect(); if parts.len() < 2 || (provider != Provider::Gitlab && parts.len() != 2) || parts.iter().any(|p| { p.is_empty() || matches!(*p, "." | "..") || p.chars().any(char::is_control) }) { return Err("Invalid repository path for the configured forge".into()); } let authority = format!("{host}{}", if ssh { "" } else { port }); let endpoint = match provider { Provider::Github => format!( "{}/repos/{}/{}/pulls", if host == "github.com" && (ssh || port.is_empty() || port == ":443") { "https://api.github.com".into() } else { format!("https://{authority}/api/v3") }, encode(parts[0]), encode(parts[1]) ), Provider::Gitlab => format!( "https://{authority}/api/v4/projects/{}/merge_requests", encode(path) ), Provider::Gitea => format!( "https://{authority}/api/v1/repos/{}/{}/pulls", encode(parts[0]), encode(parts[1]) ), }; Ok(Self { provider, host: authority, endpoint, }) } fn token(&self, root: &Path, cancelled: &AtomicBool) -> Result, String> { let names: &[&str] = match self.provider { Provider::Github if self.host == "github.com" || self.host == "github.com:443" => { &["GH_TOKEN", "GITHUB_TOKEN"] } Provider::Github => &["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"], Provider::Gitlab => &["GITLAB_TOKEN"], Provider::Gitea => &["GITEA_TOKEN"], }; let mut token = None; for name in names { match std::env::var(name) { Ok(value) if !value.is_empty() => { token = Some(value); break; } Err(std::env::VarError::NotUnicode(_)) => { return Err("Forge token is not valid UTF-8".into()); } _ => {} } } if token.is_none() && self.provider != Provider::Gitea { let mut command = Command::new(if self.provider == Provider::Github { "gh" } else { "glab" }); let host = if self.provider == Provider::Github && self.host == "github.com:443" { "github.com" } else { &self.host }; command .current_dir(root) .args(["auth", "token", "--hostname", host]); match run(command, Vec::new(), cancelled) { Ok(output) => { if let Ok(bytes) = checked(output) { token = Some( String::from_utf8(bytes) .map_err(|_| "Stored forge token is not valid UTF-8")? .trim() .to_owned(), ); } } Err(error) if cancelled.load(Ordering::Acquire) || !error.starts_with("Cannot start repository command:") => { return Err(error); } Err(_) => {} } } token .filter(|t| !t.is_empty()) .map(|token| { if token.bytes().all(|b| b.is_ascii_graphic()) { Ok(token) } else { Err("Forge token contains invalid header characters".into()) } }) .transpose() } fn get( &self, url: &str, token: Option<&str>, root: &Path, cancelled: &AtomicBool, ) -> Result { let mut headers = String::from("Accept: application/json\n"); if let Some(token) = token { let prefix = match self.provider { Provider::Github => "Authorization: Bearer", Provider::Gitlab => "PRIVATE-TOKEN:", Provider::Gitea => "Authorization: token", }; headers.push_str(&format!("{prefix} {token}\n")); } // Ignore curlrc; credentials only travel on stdin. Never follow Location or API-provided URLs. let mut command = Command::new("curl"); command.current_dir(root).args([ "--disable", "--silent", "--globoff", "--proto", "=https", "--connect-timeout", "15", "--max-time", "55", "--max-redirs", "0", "--user-agent", "vamp", "--header", "@-", "--write-out", "\n%{http_code}", "--url", url, ]); let bytes = checked(run(command, headers.into_bytes(), cancelled)?).map_err( |_| "Forge API request failed; check curl, network access, and authentication", )?; let split = bytes .iter() .rposition(|b| *b == b'\n') .ok_or("Invalid forge HTTP response")?; let status = &bytes[split + 1..]; if status != b"200" { let code = std::str::from_utf8(status) .ok() .filter(|s| s.len() == 3 && s.bytes().all(|b| b.is_ascii_digit())) .unwrap_or("unknown"); return Err(format!( "Forge API returned HTTP {code}; check remote URL and authentication (redirects are not followed)" )); } serde_json::from_slice(&bytes[..split]) .map_err(|_| "Forge API returned invalid JSON".into()) } } impl Forge { fn page_with( &self, closed: bool, page: u32, mut get: impl FnMut(&str) -> Result, ) -> Result { if page == 0 { return Err("Forge page numbers start at 1".into()); } let states: &[&str] = match (self.provider, closed) { (Provider::Gitlab, true) => &["closed", "merged"], (Provider::Gitlab, false) => &["opened"], (_, true) => &["closed"], (_, false) => &["open"], }; let mut entries = BTreeMap::new(); let mut has_more = false; for state in states { let query = match self.provider { Provider::Github => "per_page=50&sort=created&direction=desc", Provider::Gitlab => "scope=all&per_page=50&order_by=created_at&sort=desc", // Gitea's default sort is descending creation time; "oldest" reverses it. Provider::Gitea => "limit=50", }; let values = get(&format!( "{}?state={state}&{query}&page={page}", self.endpoint ))?; let values = values .as_array() .ok_or("Forge API returned a non-array pull request list")?; // Servers may cap page sizes. Only an empty page proves exhaustion. has_more |= !values.is_empty(); for value in values { let entry = summary(self.provider, value)?; if entry.closed != closed { return Err("Forge API returned a pull request in the wrong category".into()); } entries.insert(entry.number, entry); } } // GitLab needs two independently paged states for Closed. Later batches may // precede older rows from the other state; consumers sort the partial catalog. let mut entries: Vec<_> = entries.into_values().collect(); entries.sort_unstable_by(|a, b| { b.created_at .cmp(&a.created_at) .then_with(|| b.number.cmp(&a.number)) }); Ok(PullRequestPage { entries, has_more }) } fn resolve_with( &self, id: u64, mut get: impl FnMut(&str) -> Result, ) -> Result { if id == 0 { return Err("Pull request numbers start at 1".into()); } let detail = get(&format!("{}/{id}", self.endpoint))?; if number( &detail, if self.provider == Provider::Gitlab { "iid" } else { "number" }, )? != id { return Err("Forge API returned a different pull request".into()); } let preserved_head = if self.provider == Provider::Gitea { let repo = self.endpoint.strip_suffix("/pulls").unwrap(); let reference = get(&format!("{repo}/git/refs/pull/{id}/head"))?; Some(gitea_head(&reference, id)?) } else { None }; pull_request(self.provider, &detail, preserved_head) } } fn gitea_head(value: &Value, id: u64) -> Result { // Gitea versions return either an object or an array for the filtered refs API. let value = if let Some(values) = value.as_array() { if values.len() != 1 { return Err(format!( "Gitea pull request #{id} has no unique preserved head reference" )); } &values[0] } else { value }; if field(value, "ref")? != format!("refs/pull/{id}/head") { return Err("Gitea API returned a different pull request reference".into()); } let object = value .get("object") .ok_or("Gitea API returned no reference object")?; if field(object, "type")? != "commit" { return Err("Gitea API returned a non-commit pull request head".into()); } sha(object, "sha") } fn created_at(value: &Value) -> Result { let invalid = || "Forge API returned invalid created_at".to_owned(); let text = field(value, "created_at")?; let digits = |text: &str| -> Option { (!text.is_empty() && text.bytes().all(|b| b.is_ascii_digit())) .then(|| text.parse().ok()) .flatten() }; if !text.is_ascii() || text.len() < 20 || &text[4..5] != "-" || &text[7..8] != "-" || &text[10..11] != "T" || &text[13..14] != ":" || &text[16..17] != ":" { return Err(invalid()); } let mut year = digits(&text[..4]).ok_or_else(invalid)?; let mut month = digits(&text[5..7]).ok_or_else(invalid)?; let mut day = digits(&text[8..10]).ok_or_else(invalid)?; let hour = digits(&text[11..13]).ok_or_else(invalid)?; let minute = digits(&text[14..16]).ok_or_else(invalid)?; let second = digits(&text[17..19]).ok_or_else(invalid)?; let days = |year: i32, month: i32| match month { 2 => 28 + i32::from(year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)), 4 | 6 | 9 | 11 => 30, _ => 31, }; if year == 0 || !(1..=12).contains(&month) || !(1..=days(year, month)).contains(&day) || hour > 23 || minute > 59 || second > 59 { return Err(invalid()); } let mut rest = &text[19..]; let mut nanos = 0; if let Some(fraction) = rest.strip_prefix('.') { let end = fraction .find(|c: char| !c.is_ascii_digit()) .ok_or_else(invalid)?; if end == 0 || end > 9 { return Err(invalid()); } nanos = digits(&fraction[..end]).ok_or_else(invalid)? * 10i32.pow(9 - end as u32); rest = &fraction[end..]; } let offset = if rest == "Z" { 0 } else if rest.len() == 6 && matches!(&rest[..1], "+" | "-") && &rest[3..4] == ":" { let hours = digits(&rest[1..3]).ok_or_else(invalid)?; let minutes = digits(&rest[4..6]).ok_or_else(invalid)?; if hours > 23 || minutes > 59 { return Err(invalid()); } (hours * 60 + minutes) * if rest.starts_with('+') { 1 } else { -1 } } else { return Err(invalid()); }; let minutes = hour * 60 + minute - offset; day += minutes.div_euclid(1440); if day == 0 { month -= 1; if month == 0 { year -= 1; month = 12; } day = days(year, month); } else if day > days(year, month) { day = 1; month += 1; if month == 13 { year += 1; month = 1; } } if !(1..=9999).contains(&year) { return Err(invalid()); } let minutes = minutes.rem_euclid(1440); Ok(format!( "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{second:02}.{nanos:09}Z", minutes / 60, minutes % 60 )) } fn summary(provider: Provider, value: &Value) -> Result { let gitlab = provider == Provider::Gitlab; let (closed, merged) = status(provider, value)?; let author = author(provider, value)?; let state = if merged { "merged" } else if closed { "closed" } else { "open" }; let (base, head) = if gitlab { ( value.get("target_branch").and_then(Value::as_str), value.get("source_branch").and_then(Value::as_str), ) } else { ( value.pointer("/base/ref").and_then(Value::as_str), value .pointer(if provider == Provider::Gitea { "/head/label" } else { "/head/ref" }) .and_then(Value::as_str) .filter(|s| !s.is_empty()) .or_else(|| value.pointer("/head/ref").and_then(Value::as_str)), ) }; Ok(PullRequestSummary { number: number(value, if gitlab { "iid" } else { "number" })?, title: field(value, "title")?.to_owned(), description: match (base, head) { (Some(base), Some(head)) => format!("{author} · {state} · {head} → {base}"), _ => format!("{author} · {state}"), }, closed, created_at: created_at(value)?, }) } fn field<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> { value .get(key) .and_then(Value::as_str) .ok_or_else(|| format!("Forge API returned invalid {key}")) } fn number(value: &Value, key: &str) -> Result { value .get(key) .and_then(Value::as_u64) .filter(|n| *n > 0) .ok_or_else(|| format!("Forge API returned invalid {key}")) } fn sha(value: &Value, key: &str) -> Result { let sha = field(value, key)?; if matches!(sha.len(), 40 | 64) && sha.bytes().all(|b| b.is_ascii_hexdigit()) && sha.bytes().any(|b| b != b'0') { Ok(sha.to_ascii_lowercase()) } else { Err(format!("Forge API returned invalid {key} commit ID")) } } fn branch<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> { let branch = field(value, key)?; if branch.is_empty() || branch.contains(['~', '^', ':', '?', '*', '[', '\\']) || branch.contains("..") || branch.contains("@{") || branch.bytes().any(|b| b <= b' ' || b == 127) || branch .split('/') .any(|p| p.is_empty() || p.starts_with('.') || p.ends_with('.') || p.ends_with(".lock")) { Err(format!("Forge API returned invalid {key} branch")) } else { Ok(branch) } } fn status(provider: Provider, value: &Value) -> Result<(bool, bool), String> { let gitlab = provider == Provider::Gitlab; let state = field(value, "state")?; let closed = match (gitlab, state) { (false, "open") | (true, "opened" | "locked") => false, (false, "closed") | (true, "closed" | "merged") => true, _ => return Err("Forge API returned invalid pull request state".into()), }; let merged = match provider { Provider::Github => match value.get("merged_at") { Some(Value::Null) => false, Some(Value::String(date)) if !date.is_empty() => true, _ => return Err("Forge API returned invalid merged_at".into()), }, Provider::Gitlab => state == "merged", Provider::Gitea => value .get("merged") .and_then(Value::as_bool) .ok_or("Forge API returned invalid merged flag")?, }; if merged && !closed { return Err("Forge API returned inconsistent merged state".into()); } Ok((closed, merged)) } fn author(provider: Provider, value: &Value) -> Result<&str, String> { let gitlab = provider == Provider::Gitlab; let author = value .get(if gitlab { "author" } else { "user" }) .ok_or("Forge API returned no author")?; if author.is_null() { Ok("deleted user") } else { field(author, if gitlab { "username" } else { "login" }) } } fn pull_request( provider: Provider, value: &Value, preserved_head: Option, ) -> Result { let gitlab = provider == Provider::Gitlab; let number = number(value, if gitlab { "iid" } else { "number" })?; status(provider, value)?; let revisions = (|| -> Result<_, String> { let (base, head, base_sha, head_sha) = if gitlab { let refs = value.get("diff_refs").filter(|v| v.is_object()).ok_or( "GitLab merge request has no prepared diff_refs; retry after GitLab prepares it", )?; // Legacy MRs omit the target tip; their recorded merge base yields the same PR diff. ( branch(value, "target_branch")?, branch(value, "source_branch")?, sha( refs, if refs.get("start_sha").is_none_or(Value::is_null) { "base_sha" } else { "start_sha" }, )?, sha(refs, "head_sha")?, ) } else { let base = value .get("base") .ok_or("Forge API returned no base branch")?; let head = value .get("head") .ok_or("Forge API returned no head branch")?; // Gitea base.sha follows the live target branch even after merging. let base_sha = if provider == Provider::Gitea { sha(value, "merge_base")? } else { sha(base, "sha")? }; let head_key = if provider == Provider::Gitea && head .get("label") .and_then(Value::as_str) .is_some_and(|s| !s.is_empty()) { "label" } else { "ref" }; ( branch(base, "ref")?, if provider == Provider::Gitea { field(head, head_key)? } else { branch(head, head_key)? }, base_sha, if provider == Provider::Gitea { preserved_head.ok_or_else(|| { format!("Gitea pull request #{number} has no preserved head reference") })? } else { sha(head, "sha")? }, ) }; Ok(( PullRequestRevisions { base_ref: format!("refs/heads/{base}"), base_sha, head_ref: format!( "refs/{}/{number}/head", if gitlab { "merge-requests" } else { "pull" } ), head_sha, }, base, head, )) })(); Ok(PullRequest { revisions: revisions.map(|(revisions, _, _)| revisions), }) } pub(crate) fn page( url: &str, provider: Option<&str>, closed: bool, page: u32, root: &Path, cancelled: &AtomicBool, ) -> Result { let forge = Forge::parse(url, provider, |destination, port| { let mut command = Command::new("ssh"); command.current_dir(root); ssh_hostname(command, destination, port, cancelled) })?; let token = forge.token(root, cancelled)?; forge.page_with(closed, page, |url| { forge.get(url, token.as_deref(), root, cancelled) }) } pub(crate) fn resolve( url: &str, provider: Option<&str>, number: u64, root: &Path, cancelled: &AtomicBool, ) -> Result { let forge = Forge::parse(url, provider, |destination, port| { let mut command = Command::new("ssh"); command.current_dir(root); ssh_hostname(command, destination, port, cancelled) })?; let token = forge.token(root, cancelled)?; forge.resolve_with(number, |url| { forge.get(url, token.as_deref(), root, cancelled) }) } #[cfg(test)] #[path = "../tests/unit/forge.rs"] mod tests;