feat: browse GitHub GitLab and Gitea pull requests

This commit is contained in:
Valdemar Lorenzen
2026-10-14 13:22:09 +02:00
parent 15968a328c
commit af6496bd5a
11 changed files with 1349 additions and 47 deletions
+729
View File
@@ -0,0 +1,729 @@
//! 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, BTreeSet},
path::Path,
process::Command,
sync::atomic::{AtomicBool, Ordering},
};
pub(crate) struct PullRequest {
pub number: u64,
pub title: String,
pub description: String,
pub closed: bool,
pub revisions: Result<PullRequestRevisions, String>,
}
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<String, String> {
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())
}
impl Forge {
fn parse(url: &str, provider: Option<&str>) -> Result<Self, String> {
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)
};
// Never reuse URL userinfo as an API credential or include it in arguments/errors.
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::<std::net::Ipv6Addr>()
.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::<u16>().ok())
.filter(|p| *p != 0)
.is_none()
{
return Err("Invalid remote port".into());
}
let mut host = host.to_ascii_lowercase();
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.<name>.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.<name>.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<Option<String>, 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<Value, String> {
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())
}
}
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<u64, String> {
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<String, String> {
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 pull_request(
provider: Provider,
value: &Value,
preserved_head: Option<String>,
) -> Result<PullRequest, String> {
let gitlab = provider == Provider::Gitlab;
let number = number(value, if gitlab { "iid" } else { "number" })?;
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());
}
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,
))
})();
let author = value
.get(if gitlab { "author" } else { "user" })
.ok_or("Forge API returned no author")?;
let author = if author.is_null() {
"deleted user"
} else {
field(author, if gitlab { "username" } else { "login" })?
};
let state = if merged {
"merged"
} else if closed {
"closed"
} else {
"open"
};
Ok(PullRequest {
number,
title: field(value, "title")?.to_owned(),
description: match &revisions {
Ok((_, base, head)) => format!("{author} · {state} · {head} → {base}"),
Err(error) => format!("{author} · {state} · unavailable: {error}"),
},
closed,
revisions: revisions.map(|(revisions, _, _)| revisions),
})
}
pub(crate) fn list(
url: &str,
provider: Option<&str>,
root: &Path,
cancelled: &AtomicBool,
) -> Result<Vec<PullRequest>, String> {
let forge = Forge::parse(url, provider)?;
let token = forge.token(root, cancelled)?;
let mut gitea_heads = BTreeMap::new();
let query = match forge.provider {
Provider::Github => "state=all&per_page=100&sort=created&direction=asc",
Provider::Gitlab => "state=all&scope=all&per_page=100&order_by=created_at&sort=asc",
Provider::Gitea => "state=all&limit=100&sort=oldest",
};
let mut requests = Vec::new();
let mut seen = BTreeSet::new();
for page in 1u64.. {
let values = forge.get(
&format!("{}?{query}&page={page}", forge.endpoint),
token.as_deref(),
root,
cancelled,
)?;
let values = values
.as_array()
.ok_or("Forge API returned a non-array pull request list")?;
// A server may cap the requested page size. Only an empty page proves exhaustion.
if values.is_empty() {
return Ok(requests);
}
if page == 1 && forge.provider == Provider::Gitea {
let repo = forge.endpoint.strip_suffix("/pulls").unwrap();
let refs = forge.get(
&format!("{repo}/git/refs/pull"),
token.as_deref(),
root,
cancelled,
)?;
for reference in refs
.as_array()
.ok_or("Gitea API returned a non-array reference list")?
{
let name = field(reference, "ref")?;
if let Some(id) = name
.strip_prefix("refs/pull/")
.and_then(|s| s.strip_suffix("/head"))
{
let id = id
.parse::<u64>()
.ok()
.filter(|n| *n > 0)
.ok_or("Gitea API returned an invalid pull request reference")?;
let object = reference
.get("object")
.ok_or("Gitea API returned no reference object")?;
if field(object, "type")? != "commit"
|| gitea_heads.insert(id, sha(object, "sha")?).is_some()
{
return Err(
"Gitea API returned an invalid or repeated pull request head".into(),
);
}
}
}
}
for value in values {
let id = number(
value,
if forge.provider == Provider::Gitlab {
"iid"
} else {
"number"
},
)?;
if !seen.insert(id) {
return Err(
"Forge API repeated a pull request while paginating; retry the list".into(),
);
}
let detail;
// ponytail: GitLab needs one detail read per MR; batch if large histories become slow.
let value = if forge.provider == Provider::Gitlab
&& value.get("diff_refs").is_none_or(Value::is_null)
{
detail = forge.get(
&format!("{}/{id}", forge.endpoint),
token.as_deref(),
root,
cancelled,
)?;
if number(&detail, "iid")? != id {
return Err("GitLab API returned a different merge request".into());
}
&detail
} else {
value
};
let preserved_head = if forge.provider == Provider::Gitea {
gitea_heads.remove(&id)
} else {
None
};
requests.push(pull_request(forge.provider, value, preserved_head)?);
}
}
Err("Forge pagination overflow".into())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn remote_urls_preserve_namespaces_and_keep_ssh_ports_out_of_https() {
let gitlab =
Forge::parse("ssh://git@gitlab.example.org:2222/group/sub/repo.git", None).unwrap();
assert_eq!(
gitlab.endpoint,
"https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests"
);
assert_eq!(
Forge::parse("git@github.com:owner/repo.git", None)
.unwrap()
.endpoint,
"https://api.github.com/repos/owner/repo/pulls"
);
assert_eq!(
Forge::parse(
"https://user:secret@forge.example:8443/owner/repo.git",
Some("gitea")
)
.unwrap()
.endpoint,
"https://forge.example:8443/api/v1/repos/owner/repo/pulls"
);
assert!(
Forge::parse("https://forge.example/owner/repo", None)
.err()
.unwrap()
.contains("git config remote.<name>.vamp-forge github")
);
for url in [
"https://github.com/owner/repo?token=secret",
"https://github.com/owner/%2e%2e",
"https://github.com/owner/repo%0a",
"https://github.com:0/owner/repo",
"file:///owner/repo",
] {
assert!(Forge::parse(url, Some("github")).is_err(), "{url}");
}
}
#[test]
fn fork_pull_requests_pin_historical_shas_not_merge_commits_or_response_urls() {
let value = json!({"number": 7, "title": "Merged fork", "state": "closed", "merged_at": "2026-01-01T00:00:00Z", "merged": true,
"user": {"login": "author"}, "base": {"ref": "release/next", "sha": "1".repeat(40)},
"head": {"ref": "feature", "label": "fork:topic", "sha": "2".repeat(40), "repo": null}, "merge_base": "4".repeat(40), "merge_commit_sha": "3".repeat(40), "url": "https://evil.example/token"});
for provider in [Provider::Github, Provider::Gitea] {
let pr = pull_request(provider, &value, Some("5".repeat(40))).unwrap();
let revisions = pr.revisions.as_ref().unwrap();
assert_eq!(
(revisions.base_ref.as_str(), revisions.head_ref.as_str()),
("refs/heads/release/next", "refs/pull/7/head")
);
assert_eq!(
revisions.base_sha,
if provider == Provider::Gitea {
"4"
} else {
"1"
}
.repeat(40)
);
assert_eq!(
revisions.head_sha,
if provider == Provider::Gitea {
"5"
} else {
"2"
}
.repeat(40)
);
assert!(pr.closed && pr.description.contains("merged"));
if provider == Provider::Gitea {
assert!(pr.description.contains("fork:topic"));
let mut deleted = value.clone();
deleted["head"]["sha"] = Value::String(String::new());
assert_eq!(
pull_request(provider, &deleted, Some("5".repeat(40)))
.unwrap()
.revisions
.unwrap()
.head_sha,
"5".repeat(40)
);
let unavailable = pull_request(provider, &deleted, None).unwrap();
assert_eq!(unavailable.number, 7);
assert!(unavailable.closed);
assert!(unavailable.description.contains("unavailable"));
assert!(unavailable.revisions.is_err());
}
}
}
#[test]
fn gitlab_uses_target_start_sha_and_rejects_unprepared_or_unsafe_metadata() {
let mut value = json!({"iid": 9, "title": "MR", "state": "merged", "author": {"username": "author"},
"target_branch": "main", "source_branch": "topic", "diff_refs": {"base_sha": "1".repeat(40), "start_sha": "2".repeat(40), "head_sha": "3".repeat(40)}});
let pr = pull_request(Provider::Gitlab, &value, None).unwrap();
let revisions = pr.revisions.unwrap();
assert_eq!(revisions.base_sha, "2".repeat(40));
assert_eq!(revisions.head_ref, "refs/merge-requests/9/head");
value["diff_refs"]["start_sha"] = Value::Null;
assert_eq!(
pull_request(Provider::Gitlab, &value, None)
.unwrap()
.revisions
.unwrap()
.base_sha,
"1".repeat(40)
);
value["target_branch"] = json!("main:refs/heads/injected");
assert!(
pull_request(Provider::Gitlab, &value, None)
.unwrap()
.revisions
.is_err()
);
value["target_branch"] = json!("main");
value["diff_refs"]["head_sha"] = json!("HEAD");
assert!(
pull_request(Provider::Gitlab, &value, None)
.unwrap()
.revisions
.is_err()
);
value["diff_refs"] = Value::Null;
assert!(
pull_request(Provider::Gitlab, &value, None)
.unwrap()
.revisions
.is_err()
);
}
}