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
+206
View File
@@ -7,6 +7,200 @@ fn parse(url: &str, provider: Option<&str>) -> Result<Forge, String> {
})
}
#[test]
fn github_personal_approvals_use_the_viewer_and_last_decisive_review() {
let forge = parse("https://github.com/owner/repo", None).unwrap();
let values: Vec<_> = (1..=8).map(|id| json!({"number": id})).collect();
let metadata = forge.metadata_with(&values, &mut |url, body| {
if url.ends_with("/user") {
assert_eq!(url, "https://api.github.com/user");
return Ok(json!({"login": "viewer"}));
}
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
let query = body["query"].as_str().unwrap();
assert!(query.contains("author:\"viewer\""));
assert!(query.contains("states:[APPROVED,CHANGES_REQUESTED,DISMISSED],last:1"));
assert!(!query.contains("mutation"));
Ok(json!({"data":{"repository":{
"p1":{"additions":3,"deletions":4,"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"VIEWER"}}]}},
"p2":{"reviews":{"nodes":[{"state":"CHANGES_REQUESTED","author":{"login":"viewer"}}]}},
"p3":{"reviews":{"nodes":[{"state":"DISMISSED","author":{"login":"viewer"}}]}},
"p4":{"reviews":null},
"p5":{"reviews":{"nodes":[{"state":"COMMENTED","author":{"login":"viewer"}}]}},
"p6":{"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"someone-else"}}]}},
"p7":{"reviews":{"nodes":[{"state":"PENDING","author":{"login":"viewer"}}]}},
"p8":{"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"viewer"}}]}}
}}}))
});
assert_eq!(
metadata
.iter()
.filter_map(|(id, details)| details.approved.then_some(*id))
.collect::<Vec<_>>(),
[1, 8]
);
assert_eq!(metadata[&1].size, Some((3, 4)));
assert_eq!(metadata[&8].size, None);
}
#[test]
fn gitlab_personal_approvals_filter_by_viewer_and_follow_connection_pages() {
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
let values = [json!({"iid":7}), json!({"iid":8}), json!({"iid":9})];
let metadata = forge.metadata_with(&values, &mut |url, body| {
if url.ends_with("/user") {
assert_eq!(url, "https://gitlab.com/api/v4/user");
return Ok(json!({"username":"viewer"}));
}
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
let query = body["query"].as_str().unwrap();
assert!(query.contains("approvedBy:[\"viewer\"]"));
assert!(query.contains("iids:[\"7\",\"8\",\"9\"]"));
assert!(!query.contains("mutation"));
if query.contains("after:\"next-page\"") {
return Ok(json!({"data":{"project":{"approved":{
"nodes":[{"iid":"8"}],
"pageInfo":{"hasNextPage":false,"endCursor":null}
}}}}));
}
Ok(json!({"data":{"project":{
"p7":{"diffStatsSummary":{"additions":5,"deletions":6}},
"p8":{"diffStatsSummary":null},
"p9":{"diffStatsSummary":{"additions":0,"deletions":0}},
"approved":{
"nodes":[{"iid":"7"},{"iid":"999"}],
"pageInfo":{"hasNextPage":true,"endCursor":"next-page"}
}
}}}))
});
assert!(metadata[&7].approved && metadata[&8].approved);
assert!(!metadata[&9].approved);
assert!(!metadata.contains_key(&999));
assert_eq!(metadata[&7].size, Some((5, 6)));
assert_eq!(metadata[&8].size, None);
}
#[test]
fn gitea_personal_approvals_ignore_other_users_and_honor_later_decisions_and_failures() {
let forge = parse("https://codeberg.org/owner/repo", None).unwrap();
let own = |id, state: &str, dismissed| json!({"id":id,"user":{"id":42},"state":state,"dismissed":dismissed});
let approved = own(10, "APPROVED", false);
for (pages, expected) in [
(
vec![
Ok(json!([approved.clone()])),
Ok(json!([
own(30, "COMMENT", false),
own(31, "PENDING", false),
own(32, "REQUEST_REVIEW", false)
])),
Ok(json!([])),
],
true,
),
(
vec![
Ok(json!([approved.clone()])),
Ok(json!([own(20, "REQUEST_CHANGES", false)])),
Ok(json!([])),
],
false,
),
(
vec![
Ok(json!([approved.clone()])),
Ok(json!([own(20, "APPROVED", true)])),
Ok(json!([])),
],
false,
),
(
vec![
Ok(json!([{"id":10,"user":{"id":7},"state":"APPROVED","dismissed":false}])),
Ok(json!([])),
],
false,
),
(
vec![
Ok(json!([approved.clone()])),
Err("later review page failed".into()),
],
false,
),
(
vec![Ok(json!([{"id":10,"user":{"id":42},"state":"APPROVED"}]))],
false,
),
// Select by review identity, not response order; a later approval replaces a rejection.
(
vec![
Ok(json!([
own(20, "APPROVED", false),
own(10, "REQUEST_CHANGES", false)
])),
Ok(json!([])),
],
true,
),
] {
let mut pages = pages.into_iter();
let metadata = forge.metadata_with(
&[json!({"number":7,"additions":1,"deletions":2})],
&mut |url, body| {
assert!(body.is_none());
if url.ends_with("/user") {
assert_eq!(url, "https://codeberg.org/api/v1/user");
return Ok(json!({"id":42}));
}
assert!(url.starts_with(
"https://codeberg.org/api/v1/repos/owner/repo/pulls/7/reviews?limit=50&page="
));
pages.next().expect("unexpected review page")
},
);
assert_eq!(metadata[&7].approved, expected);
assert_eq!(metadata[&7].size, Some((1, 2)));
}
}
#[test]
fn unavailable_viewer_or_metadata_never_fabricates_personal_approval() {
for (url, provider) in [
("https://github.com/owner/repo", Provider::Github),
("https://gitlab.com/group/repo", Provider::Gitlab),
("https://codeberg.org/owner/repo", Provider::Gitea),
] {
let forge = parse(url, None).unwrap();
for unavailable_viewer in [true, false] {
let metadata = forge.metadata_with(
&[json!({"number":7,"iid":7,"additions":0,"deletions":0})],
&mut |url, body| {
if url.ends_with("/user") {
return if unavailable_viewer {
Err("no API credentials".into())
} else {
Ok(json!({"login":"viewer","username":"viewer","id":42}))
};
}
if provider == Provider::Gitea {
assert!(
!unavailable_viewer,
"anonymous listings must not fetch reviews"
);
assert!(body.is_none());
}
Err("metadata denied".into())
},
);
assert!(metadata.values().all(|details| !details.approved));
if provider == Provider::Gitea {
assert_eq!(metadata[&7].size, Some((0, 0)));
}
}
}
}
#[test]
fn draft_summaries_keep_open_closed_and_merged_states_distinct() {
for provider in [Provider::Github, Provider::Gitlab, Provider::Gitea] {
@@ -62,6 +256,9 @@ fn pull_request_sizes_include_zero_and_preserve_unknown_statistics() {
.unwrap();
let page = forge
.page_with(false, 1, |url, body| {
if url.ends_with("/user") {
return Err("unauthenticated fixture".into());
}
if let Some(body) = body {
let query: Value = serde_json::from_str(body).unwrap();
let query = query["query"].as_str().unwrap();
@@ -140,6 +337,9 @@ fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
] {
let forge = parse(url, None).unwrap();
let page = forge.page_with(false, 3, |url, body| {
if url.ends_with("/user") {
return Err("unauthenticated fixture".into());
}
if body.is_some() {
return Err("statistics unavailable".into());
}
@@ -179,6 +379,9 @@ fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
let mut states = Vec::new();
let result = forge
.page_with(true, page, |url, body| {
if url.ends_with("/user") {
return Err("unauthenticated fixture".into());
}
if body.is_some() {
return Err("statistics unavailable".into());
}
@@ -215,6 +418,9 @@ fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
all.sort_unstable_by(|a, b| b.created_at.cmp(&a.created_at));
assert_eq!(all.iter().map(|p| p.number).collect::<Vec<_>>(), [3, 2, 1]);
let raced = forge.page_with(true, 1, |url, body| {
if url.ends_with("/user") {
return Err("unauthenticated fixture".into());
}
if body.is_some() {
return Err("statistics unavailable".into());
}