feat: mark pull requests approved by the authenticated user

This commit is contained in:
Valdemar Lorenzen
2026-10-21 10:46:24 +02:00
parent 6a359cc351
commit bd71d463a4
7 changed files with 422 additions and 25 deletions
+196 -22
View File
@@ -14,9 +14,15 @@ pub(crate) struct PullRequestSummary {
pub description: String,
pub closed: bool,
pub draft: bool,
pub approved: bool,
pub created_at: String,
}
struct PullRequestMetadata {
size: Option<(u64, u64)>,
approved: bool,
}
pub(crate) struct PullRequestPage {
pub entries: Vec<PullRequestSummary>,
pub has_more: bool,
@@ -413,7 +419,7 @@ impl Forge {
.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);
let metadata = self.metadata_with(values, &mut request);
for value in values {
let id = number(
value,
@@ -423,7 +429,9 @@ impl Forge {
"number"
},
)?;
let entry = summary(self.provider, value, sizes.get(&id).copied())?;
let details = metadata.get(&id);
let mut entry = summary(self.provider, value, details.and_then(|d| d.size))?;
entry.approved = details.is_some_and(|d| d.approved);
if entry.closed != closed {
return Err("Forge API returned a pull request in the wrong category".into());
}
@@ -441,29 +449,68 @@ impl Forge {
Ok(PullRequestPage { entries, has_more })
}
/// One statistics query per page, never one detail request per pull request.
fn sizes_with(
/// Batch GitHub/GitLab metadata; Gitea exposes reviews only per pull request.
fn metadata_with(
&self,
values: &[Value],
request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>,
) -> BTreeMap<u64, (u64, u64)> {
) -> BTreeMap<u64, PullRequestMetadata> {
if values.is_empty() {
return BTreeMap::new();
}
let api = self
.endpoint
.split_once(if self.provider == Provider::Gitlab {
"/projects/"
} else {
"/repos/"
})
.unwrap()
.0;
let viewer = request(&format!("{api}/user"), None).ok();
let username = viewer.as_ref().and_then(|viewer| {
viewer
.get(if self.provider == Provider::Gitlab {
"username"
} else {
"login"
})
.and_then(Value::as_str)
.filter(|name| !name.is_empty())
});
if self.provider == Provider::Gitea {
let viewer = viewer.as_ref().and_then(|viewer| number(viewer, "id").ok());
return values
.iter()
.filter_map(|value| {
let id = number(value, "number").ok()?;
Some((
value.get("number")?.as_u64()?,
(
value.get("additions")?.as_u64()?,
value.get("deletions")?.as_u64()?,
),
id,
PullRequestMetadata {
size: value
.get("additions")
.and_then(Value::as_u64)
.zip(value.get("deletions").and_then(Value::as_u64)),
// ponytail: Gitea has no batch reviews API; per-PR reads until it does.
approved: viewer.is_some_and(|viewer| {
self.gitea_approved_with(id, viewer, request)
.unwrap_or(false)
}),
},
))
})
.collect();
}
let reviews = if self.provider == Provider::Github {
username.map_or_else(String::new, |username| {
format!(
"reviews(author:{},states:[APPROVED,CHANGES_REQUESTED,DISMISSED],last:1){{nodes{{state author{{login}}}}}}",
serde_json::to_string(username).unwrap()
)
})
} else {
String::new()
};
let mut fields = String::new();
for value in values {
let Some(id) = value
@@ -480,7 +527,7 @@ impl Forge {
match self.provider {
Provider::Github => write!(
fields,
"p{id}:pullRequest(number:{id}){{additions deletions}}"
"p{id}:pullRequest(number:{id}){{additions deletions {reviews}}}"
)
.unwrap(),
Provider::Gitlab => write!(
@@ -491,6 +538,30 @@ impl Forge {
Provider::Gitea => unreachable!(),
}
}
let approval_args = if self.provider == Provider::Gitlab {
username.map(|username| {
let ids: Vec<_> = values
.iter()
.filter_map(|value| value.get("iid")?.as_u64().map(|id| id.to_string()))
.collect();
format!(
"iids:{},approvedBy:[{}],first:{}",
serde_json::to_string(&ids).unwrap(),
serde_json::to_string(username).unwrap(),
ids.len()
)
})
} else {
None
};
if let Some(args) = &approval_args {
use std::fmt::Write;
write!(
fields,
"approved:mergeRequests({args}){{nodes{{iid}} pageInfo{{hasNextPage endCursor}}}}"
)
.unwrap();
}
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;
@@ -512,7 +583,7 @@ impl Forge {
)
};
let body = serde_json::json!({ "query": query }).to_string();
// Missing/denied statistics must not prevent browsing or imply a zero-size PR.
// Missing/denied metadata must not prevent browsing or imply approval.
let Ok(response) = request(&url, Some(&body)) else {
return BTreeMap::new();
};
@@ -524,23 +595,125 @@ impl Forge {
let Some(entries) = response.pointer(container).and_then(Value::as_object) else {
return BTreeMap::new();
};
entries
let mut metadata: BTreeMap<_, _> = entries
.iter()
.filter_map(|(alias, value)| {
let value = if self.provider == Provider::Gitlab {
value.get("diffStatsSummary")?
let stats = if self.provider == Provider::Gitlab {
value.get("diffStatsSummary")
} else {
value
Some(value)
};
let size = stats.and_then(|stats| {
stats
.get("additions")?
.as_u64()
.zip(stats.get("deletions")?.as_u64())
});
let approved = self.provider == Provider::Github
&& username.is_some_and(|username| {
value
.pointer("/reviews/nodes")
.and_then(Value::as_array)
.is_some_and(|reviews| {
reviews.len() == 1
&& reviews[0]["state"] == "APPROVED"
&& reviews[0]
.pointer("/author/login")
.and_then(Value::as_str)
.is_some_and(|author| author.eq_ignore_ascii_case(username))
})
});
Some((
alias.strip_prefix('p')?.parse().ok()?,
(
value.get("additions")?.as_u64()?,
value.get("deletions")?.as_u64()?,
),
alias.strip_prefix('p')?.parse::<u64>().ok()?,
PullRequestMetadata { size, approved },
))
})
.collect()
.collect();
if let Some(args) = approval_args {
let mut response = response;
let mut cursor = None::<String>;
loop {
let Some(approvals) = response.pointer("/data/project/approved") else {
break;
};
if let Some(nodes) = approvals.get("nodes").and_then(Value::as_array) {
for node in nodes {
if let Some(details) = node
.get("iid")
.and_then(Value::as_str)
.and_then(|id| id.parse::<u64>().ok())
.and_then(|id| metadata.get_mut(&id))
{
details.approved = true;
}
}
}
if approvals.pointer("/pageInfo/hasNextPage") != Some(&Value::Bool(true)) {
break;
}
let Some(after) = approvals
.pointer("/pageInfo/endCursor")
.and_then(Value::as_str)
.filter(|after| !after.is_empty() && Some(*after) != cursor.as_deref())
else {
break;
};
cursor = Some(after.to_owned());
let body = serde_json::json!({
"query": format!(
"query{{project(fullPath:{}){{approved:mergeRequests({args},after:{}){{nodes{{iid}} pageInfo{{hasNextPage endCursor}}}}}}}}",
serde_json::to_string(&self.repository).unwrap(),
serde_json::to_string(after).unwrap()
)
}).to_string();
match request(&url, Some(&body)) {
Ok(next) => response = next,
Err(_) => break,
}
}
}
metadata
}
fn gitea_approved_with(
&self,
id: u64,
viewer: u64,
request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>,
) -> Result<bool, String> {
let mut latest = None::<(u64, bool)>;
for page in 1.. {
let response = request(
&format!("{}/{id}/reviews?limit=50&page={page}", self.endpoint),
None,
)?;
let reviews = response
.as_array()
.ok_or("Forge API returned a non-array review list")?;
if reviews.is_empty() {
return Ok(latest.is_some_and(|(_, approved)| approved));
}
for review in reviews {
if review.pointer("/user/id").and_then(Value::as_u64) != Some(viewer) {
continue;
}
let state = field(review, "state")?;
match state {
"COMMENT" | "PENDING" | "REQUEST_REVIEW" => continue,
"APPROVED" | "REQUEST_CHANGES" => {}
_ => return Err("Forge API returned invalid review state".into()),
}
let id = number(review, "id")?;
let dismissed = review
.get("dismissed")
.and_then(Value::as_bool)
.ok_or("Forge API returned invalid dismissed flag")?;
if latest.is_none_or(|(previous, _)| id > previous) {
latest = Some((id, state == "APPROVED" && !dismissed));
}
}
}
unreachable!()
}
fn resolve_with(
@@ -743,6 +916,7 @@ fn summary(
},
closed,
draft,
approved: false,
created_at: created_at(value)?,
})
}
+1
View File
@@ -107,6 +107,7 @@ Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/Drafts/All tabs, type to search
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
✓ before the PR number marks your API account's active approval; blank may mean unavailable
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base
config.resolve_history_layout = \"columns\" (left/base/right) or \"base_below\" (left/right above base)
+6 -1
View File
@@ -1631,7 +1631,12 @@ impl Repository {
remote: remote.clone(),
number: pull.number,
},
label: format!("#{} {}", pull.number, pull.title),
label: format!(
"{}#{} {}",
if pull.approved { "✓ " } else { " " },
pull.number,
pull.title
),
description: pull.description,
category: if pull.closed {
Category::Closed
+5 -1
View File
@@ -3559,7 +3559,11 @@ fn draw_diff(
lines.push(Line::styled(visible_line(&label, 0, width), style));
lines.push(Line::styled(
visible_line(
&format!(" {}", display_text(&revision.description)),
&format!(
"{}{}",
if pull_requests { " " } else { " " },
display_text(&revision.description)
),
0,
width,
),