817 lines
28 KiB
Rust
817 lines
28 KiB
Rust
//! 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<PullRequestSummary>,
|
|
pub has_more: bool,
|
|
}
|
|
|
|
pub(crate) struct PullRequest {
|
|
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())
|
|
}
|
|
|
|
fn ssh_hostname(
|
|
mut command: Command,
|
|
destination: &str,
|
|
port: &str,
|
|
cancelled: &AtomicBool,
|
|
) -> Result<String, String> {
|
|
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<String, String>,
|
|
) -> 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)
|
|
};
|
|
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::<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 = resolve_ssh(&destination[..destination.len() - port.len()], port)?
|
|
.to_ascii_lowercase();
|
|
let ipv6 = host.trim_start_matches('[').trim_end_matches(']');
|
|
if ipv6.parse::<std::net::Ipv6Addr>().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.<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())
|
|
}
|
|
}
|
|
|
|
impl Forge {
|
|
fn page_with(
|
|
&self,
|
|
closed: bool,
|
|
page: u32,
|
|
mut get: impl FnMut(&str) -> Result<Value, String>,
|
|
) -> Result<PullRequestPage, String> {
|
|
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<Value, String>,
|
|
) -> Result<PullRequest, String> {
|
|
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<String, String> {
|
|
// 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<String, String> {
|
|
let invalid = || "Forge API returned invalid created_at".to_owned();
|
|
let text = field(value, "created_at")?;
|
|
let digits = |text: &str| -> Option<i32> {
|
|
(!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<PullRequestSummary, String> {
|
|
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<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 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<String>,
|
|
) -> Result<PullRequest, String> {
|
|
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<PullRequestPage, String> {
|
|
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<PullRequest, String> {
|
|
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;
|