feat: show line counts in pull request picker

This commit is contained in:
Valdemar Lorenzen
2026-10-21 10:06:48 +02:00
parent 31203889bb
commit cce3b1e469
4 changed files with 234 additions and 22 deletions
+141 -13
View File
@@ -42,6 +42,7 @@ struct Forge {
provider: Provider,
host: String,
endpoint: String,
repository: String,
}
fn encode(text: &str) -> String {
@@ -237,6 +238,7 @@ impl Forge {
provider,
host: authority,
endpoint,
repository: path.to_owned(),
})
}
@@ -308,14 +310,18 @@ impl Forge {
.transpose()
}
fn get(
fn request(
&self,
url: &str,
body: Option<&str>,
token: Option<&str>,
root: &Path,
cancelled: &AtomicBool,
) -> Result<Value, String> {
let mut headers = String::from("Accept: application/json\n");
if body.is_some() {
headers.push_str("Content-Type: application/json\n");
}
if let Some(token) = token {
let prefix = match self.provider {
Provider::Github => "Authorization: Bearer",
@@ -347,6 +353,9 @@ impl Forge {
"--url",
url,
]);
if let Some(body) = body {
command.args(["--data-binary", body]);
}
let bytes = checked(run(command, headers.into_bytes(), cancelled)?).map_err(
|_| "Forge API request failed; check curl, network access, and authentication",
)?;
@@ -374,7 +383,7 @@ impl Forge {
&self,
closed: bool,
page: u32,
mut get: impl FnMut(&str) -> Result<Value, String>,
mut request: impl FnMut(&str, Option<&str>) -> Result<Value, String>,
) -> Result<PullRequestPage, String> {
if page == 0 {
return Err("Forge page numbers start at 1".into());
@@ -394,17 +403,26 @@ impl Forge {
// 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 = request(
&format!("{}?state={state}&{query}&page={page}", self.endpoint),
None,
)?;
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();
let sizes = self.sizes_with(values, &mut request);
for value in values {
let entry = summary(self.provider, value)?;
let id = number(
value,
if self.provider == Provider::Gitlab {
"iid"
} else {
"number"
},
)?;
let entry = summary(self.provider, value, sizes.get(&id).copied())?;
if entry.closed != closed {
return Err("Forge API returned a pull request in the wrong category".into());
}
@@ -422,6 +440,108 @@ impl Forge {
Ok(PullRequestPage { entries, has_more })
}
/// One statistics query per page, never one detail request per pull request.
fn sizes_with(
&self,
values: &[Value],
request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>,
) -> BTreeMap<u64, (u64, u64)> {
if values.is_empty() {
return BTreeMap::new();
}
if self.provider == Provider::Gitea {
return values
.iter()
.filter_map(|value| {
Some((
value.get("number")?.as_u64()?,
(
value.get("additions")?.as_u64()?,
value.get("deletions")?.as_u64()?,
),
))
})
.collect();
}
let mut fields = String::new();
for value in values {
let Some(id) = value
.get(if self.provider == Provider::Gitlab {
"iid"
} else {
"number"
})
.and_then(Value::as_u64)
else {
continue;
};
use std::fmt::Write;
match self.provider {
Provider::Github => write!(
fields,
"p{id}:pullRequest(number:{id}){{additions deletions}}"
)
.unwrap(),
Provider::Gitlab => write!(
fields,
"p{id}:mergeRequest(iid:\"{id}\"){{diffStatsSummary{{additions deletions}}}}"
)
.unwrap(),
Provider::Gitea => unreachable!(),
}
}
let (url, query) = if self.provider == Provider::Github {
let (owner, name) = self.repository.split_once('/').unwrap();
let api = self.endpoint.split_once("/repos/").unwrap().0;
(
format!("{}/graphql", api.strip_suffix("/v3").unwrap_or(api)),
format!(
"query{{repository(owner:{},name:{}){{{fields}}}}}",
serde_json::to_string(owner).unwrap(),
serde_json::to_string(name).unwrap()
),
)
} else {
(
format!("https://{}/api/graphql", self.host),
format!(
"query{{project(fullPath:{}){{{fields}}}}}",
serde_json::to_string(&self.repository).unwrap()
),
)
};
let body = serde_json::json!({ "query": query }).to_string();
// Missing/denied statistics must not prevent browsing or imply a zero-size PR.
let Ok(response) = request(&url, Some(&body)) else {
return BTreeMap::new();
};
let container = if self.provider == Provider::Github {
"/data/repository"
} else {
"/data/project"
};
let Some(entries) = response.pointer(container).and_then(Value::as_object) else {
return BTreeMap::new();
};
entries
.iter()
.filter_map(|(alias, value)| {
let value = if self.provider == Provider::Gitlab {
value.get("diffStatsSummary")?
} else {
value
};
Some((
alias.strip_prefix('p')?.parse().ok()?,
(
value.get("additions")?.as_u64()?,
value.get("deletions")?.as_u64()?,
),
))
})
.collect()
}
fn resolve_with(
&self,
id: u64,
@@ -567,7 +687,11 @@ fn created_at(value: &Value) -> Result<String, String> {
))
}
fn summary(provider: Provider, value: &Value) -> Result<PullRequestSummary, String> {
fn summary(
provider: Provider,
value: &Value,
size: Option<(u64, u64)>,
) -> Result<PullRequestSummary, String> {
let gitlab = provider == Provider::Gitlab;
let (closed, merged) = status(provider, value)?;
let author = author(provider, value)?;
@@ -578,6 +702,10 @@ fn summary(provider: Provider, value: &Value) -> Result<PullRequestSummary, Stri
} else {
"open"
};
let size = size.map_or_else(
|| "size unavailable".into(),
|(added, deleted)| format!("+{added} -{deleted}"),
);
let (base, head) = if gitlab {
(
value.get("target_branch").and_then(Value::as_str),
@@ -601,8 +729,8 @@ fn summary(provider: Provider, value: &Value) -> Result<PullRequestSummary, Stri
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}"),
(Some(base), Some(head)) => format!("{author} · {state} · {size} · {head} → {base}"),
_ => format!("{author} · {state} · {size}"),
},
closed,
created_at: created_at(value)?,
@@ -788,8 +916,8 @@ pub(crate) fn page(
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)
forge.page_with(closed, page, |url, body| {
forge.request(url, body, token.as_deref(), root, cancelled)
})
}
@@ -807,7 +935,7 @@ pub(crate) fn resolve(
})?;
let token = forge.token(root, cancelled)?;
forge.resolve_with(number, |url| {
forge.get(url, token.as_deref(), root, cancelled)
forge.request(url, None, token.as_deref(), root, cancelled)
})
}