1057 lines
42 KiB
Rust
1057 lines
42 KiB
Rust
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn parse(url: &str, provider: Option<&str>) -> Result<Forge, String> {
|
|
Forge::parse(url, provider, |destination, _| {
|
|
Ok(destination.rsplit('@').next().unwrap().to_owned())
|
|
})
|
|
}
|
|
|
|
#[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,
|
|
"requested_reviewers":[{"login":"viewer","id":42}],
|
|
"reviewers":[{"username":"viewer"}]})],
|
|
&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));
|
|
assert!(metadata.values().all(|details| details.review_requested
|
|
== (provider == Provider::Github && !unavailable_viewer)));
|
|
if provider == Provider::Gitea {
|
|
assert_eq!(metadata[&7].size, Some((0, 0)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn github_outstanding_requests_use_list_users_independently_of_review_metadata() {
|
|
let forge = parse("https://github.com/owner/repo", None).unwrap();
|
|
for (reviewers, expected) in [
|
|
(json!([{"login":"VIEWER"}]), true), // Direct or re-requested after approval.
|
|
(json!([]), false), // Completed or removed requests are absent.
|
|
(json!([{"login":"another-user"}]), false),
|
|
(Value::Null, false),
|
|
] {
|
|
for denied in [false, true] {
|
|
let metadata = forge.metadata_with(
|
|
&[json!({"number":7,"requested_reviewers":reviewers,
|
|
"requested_teams":[{"slug":"viewer"}],"additions":3,"deletions":4})],
|
|
&mut |url, body| {
|
|
if url.ends_with("/user") {
|
|
return Ok(json!({"login":"viewer"}));
|
|
}
|
|
assert!(body.is_some());
|
|
if denied {
|
|
return Err("GraphQL denied".into());
|
|
}
|
|
Ok(
|
|
json!({"data":{"repository":{"p7":{"additions":3,"deletions":4,
|
|
"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"viewer"}}]}}}}}),
|
|
)
|
|
},
|
|
);
|
|
assert_eq!(metadata[&7].review_requested, expected);
|
|
assert_eq!(metadata[&7].approved, !denied);
|
|
assert_eq!(metadata[&7].size, Some((3, 4)));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gitlab_outstanding_requests_require_personal_unfinished_review_and_follow_pages() {
|
|
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
|
|
let mut values: Vec<_> = (1..=10)
|
|
.map(|id| json!({"iid":id,"reviewers":[{"username":"viewer"}]}))
|
|
.collect();
|
|
values[8]["reviewers"] = json!([]); // Removed reviewer.
|
|
values[9]["reviewers"] = json!([{"username":"another-user"}]);
|
|
let mut request_reads = 0;
|
|
let metadata = forge.metadata_with(&values, &mut |url, body| {
|
|
if url.ends_with("/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("mutation"));
|
|
if !query.contains("reviewState") {
|
|
return Ok(json!({"data":{"project":{
|
|
"p1":{"diffStatsSummary":{"additions":5,"deletions":6}},
|
|
"approved":{"nodes":[{"iid":"1"},{"iid":"4"}],"pageInfo":{"hasNextPage":false}}
|
|
}}}));
|
|
}
|
|
request_reads += 1;
|
|
assert!(query.contains("reviewers(first:20"));
|
|
assert!(!query.contains("p9:") && !query.contains("p10:"));
|
|
if query.contains("after:\"next\"") {
|
|
return Ok(json!({"data":{"project":{"p2":{"reviewers":{
|
|
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":"REVIEW_STARTED"}}],
|
|
"pageInfo":{"hasNextPage":false}
|
|
}}}}}));
|
|
}
|
|
let mut entries = serde_json::Map::new();
|
|
for (id, state) in [(1, "UNREVIEWED"), (3, "REVIEWED"), (4, "APPROVED"),
|
|
(5, "REQUESTED_CHANGES"), (6, "UNAPPROVED"), (7, ""), (8, "UNKNOWN")] {
|
|
entries.insert(format!("p{id}"), json!({"reviewers":{
|
|
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":state}}],
|
|
"pageInfo":{"hasNextPage":false}
|
|
}}));
|
|
}
|
|
entries.insert("p2".into(), json!({"reviewers":{
|
|
"nodes":[{"username":"another-user","mergeRequestInteraction":{"reviewState":"UNREVIEWED"}}],
|
|
"pageInfo":{"hasNextPage":true,"endCursor":"next"}
|
|
}}));
|
|
Ok(json!({"data":{"project":entries}}))
|
|
});
|
|
assert_eq!(request_reads, 2);
|
|
assert_eq!(
|
|
metadata
|
|
.iter()
|
|
.filter_map(|(id, details)| details.review_requested.then_some(*id))
|
|
.collect::<Vec<_>>(),
|
|
[1, 2]
|
|
);
|
|
assert!(metadata[&1].approved && metadata[&4].approved);
|
|
assert_eq!(metadata[&1].size, Some((5, 6)));
|
|
}
|
|
|
|
#[test]
|
|
fn gitlab_request_failures_preserve_approval_and_statistics() {
|
|
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
|
|
for denied in [
|
|
Err("review states denied".into()),
|
|
Ok(json!({"data":{"project":{
|
|
"p7":{"reviewers":null}
|
|
}}})),
|
|
Ok(json!({"data":{"project":{"p7":{"reviewers":{"nodes":[
|
|
{"username":"viewer","mergeRequestInteraction":{"reviewState":null}}
|
|
]}}}}})),
|
|
] {
|
|
let metadata = forge.metadata_with(
|
|
&[json!({"iid":7,"reviewers":[{"username":"viewer"}]})],
|
|
&mut |url, body| {
|
|
if url.ends_with("/user") {
|
|
return Ok(json!({"username":"viewer"}));
|
|
}
|
|
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
|
|
if body["query"].as_str().unwrap().contains("reviewState") {
|
|
return denied.clone();
|
|
}
|
|
Ok(json!({"data":{"project":{
|
|
"p7":{"diffStatsSummary":{"additions":0,"deletions":1}},
|
|
"approved":{"nodes":[{"iid":"7"}],"pageInfo":{"hasNextPage":false}}
|
|
}}}))
|
|
},
|
|
);
|
|
assert!(!metadata[&7].review_requested);
|
|
assert!(metadata[&7].approved);
|
|
assert_eq!(metadata[&7].size, Some((0, 1)));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gitlab_request_batches_cover_fifty_rows_without_exceeding_complexity_limit() {
|
|
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
|
|
let values: Vec<_> = (1..=50)
|
|
.map(|id| {
|
|
json!({
|
|
"iid":id,"reviewers":[{"username":"viewer"}]
|
|
})
|
|
})
|
|
.collect();
|
|
let mut batches = 0;
|
|
let metadata = forge.metadata_with(&values, &mut |url, body| {
|
|
if url.ends_with("/user") {
|
|
return Ok(json!({"username":"viewer"}));
|
|
}
|
|
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
|
|
let query = body["query"].as_str().unwrap();
|
|
if !query.contains("reviewState") {
|
|
return Err("independent metadata unavailable".into());
|
|
}
|
|
batches += 1;
|
|
assert_eq!(query.matches("reviewers(first:20)").count(), 10);
|
|
let entries: serde_json::Map<_, _> = (1..=50)
|
|
.filter(|id| query.contains(&format!("p{id}:mergeRequest")))
|
|
.map(|id| (format!("p{id}"), json!({"reviewers":{
|
|
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":"UNREVIEWED"}}],
|
|
"pageInfo":{"hasNextPage":false}
|
|
}}))).collect();
|
|
Ok(json!({"data":{"project":entries}}))
|
|
});
|
|
assert_eq!(batches, 5);
|
|
assert_eq!(metadata.len(), 50);
|
|
assert!(metadata.values().all(|details| details.review_requested));
|
|
}
|
|
|
|
#[test]
|
|
fn gitea_requests_ignore_list_assignments_and_use_current_direct_review_rows() {
|
|
let forge = parse("https://codeberg.org/owner/repo", None).unwrap();
|
|
let own = |id, state: &str| {
|
|
json!({
|
|
"id":id,"user":{"id":42},"team":null,"state":state,"dismissed":false
|
|
})
|
|
};
|
|
for (reviews, requested, approved) in [
|
|
(json!([own(10, "REQUEST_REVIEW")]), true, false),
|
|
(json!([own(10, "APPROVED")]), false, true), // Completed or removed request row.
|
|
(json!([own(10, "COMMENT")]), false, false),
|
|
(json!([own(10, "REQUEST_CHANGES")]), false, false),
|
|
(
|
|
json!([own(10, "APPROVED"), own(20, "REQUEST_REVIEW")]),
|
|
true,
|
|
true,
|
|
),
|
|
(
|
|
json!([
|
|
own(20, "REQUEST_REVIEW"),
|
|
own(10, "APPROVED"),
|
|
own(30, "PENDING")
|
|
]),
|
|
true,
|
|
true,
|
|
),
|
|
(
|
|
json!([own(10, "REQUEST_REVIEW"), own(20, "COMMENT")]),
|
|
false,
|
|
false,
|
|
),
|
|
(
|
|
json!([own(10, "REQUEST_REVIEW"), own(20, "APPROVED")]),
|
|
false,
|
|
true,
|
|
),
|
|
(
|
|
json!([{"id":10,"user":{"id":7},"state":"REQUEST_REVIEW","dismissed":false}]),
|
|
false,
|
|
false,
|
|
),
|
|
(
|
|
json!([{"id":10,"user":{"id":42},"team":{"id":1},"state":"REQUEST_REVIEW","dismissed":false}]),
|
|
false,
|
|
false,
|
|
),
|
|
(
|
|
json!([{"id":20,"user":{"id":42},"state":"REQUEST_REVIEW"},
|
|
own(10, "APPROVED")]),
|
|
false,
|
|
true,
|
|
),
|
|
(json!([]), false, false),
|
|
] {
|
|
let mut pages = vec![reviews, json!([])].into_iter();
|
|
let metadata = forge.metadata_with(
|
|
&[json!({"number":7,"additions":1,"deletions":2,"requested_reviewers":[{"id":42}]})],
|
|
&mut |url, body| {
|
|
assert!(body.is_none());
|
|
if url.ends_with("/user") {
|
|
return Ok(json!({"id":42}));
|
|
}
|
|
pages.next().ok_or_else(|| "unexpected page".into())
|
|
},
|
|
);
|
|
assert_eq!(metadata[&7].review_requested, requested);
|
|
assert_eq!(metadata[&7].approved, approved);
|
|
assert_eq!(metadata[&7].size, Some((1, 2)));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gitea_incomplete_request_pages_do_not_fabricate_requests_or_lose_list_statistics() {
|
|
let forge = parse("https://codeberg.org/owner/repo", None).unwrap();
|
|
let metadata = forge.metadata_with(
|
|
&[json!({"number":7,"additions":0,"deletions":2})],
|
|
&mut |url, _| {
|
|
if url.ends_with("/user") {
|
|
return Ok(json!({"id":42}));
|
|
}
|
|
if url.ends_with("page=1") {
|
|
return Ok(json!([{"id":10,"user":{"id":42},"team":null,
|
|
"state":"REQUEST_REVIEW","dismissed":false}]));
|
|
}
|
|
Err("remaining reviews denied".into())
|
|
},
|
|
);
|
|
assert!(!metadata[&7].review_requested);
|
|
assert_eq!(metadata[&7].size, Some((0, 2)));
|
|
}
|
|
|
|
#[test]
|
|
fn summaries_keep_outstanding_requests_and_approvals_independent() {
|
|
for (url, provider) in [
|
|
("https://github.com/owner/repo", Provider::Github),
|
|
("https://gitlab.com/owner/repo", Provider::Gitlab),
|
|
("https://codeberg.org/owner/repo", Provider::Gitea),
|
|
] {
|
|
let forge = parse(url, None).unwrap();
|
|
let page = forge.page_with(false, 1, |url, body| {
|
|
if url.ends_with("/user") {
|
|
return Ok(json!({"id":42,"login":"viewer","username":"viewer"}));
|
|
}
|
|
if let Some(body) = body {
|
|
let body: Value = serde_json::from_str(body).unwrap();
|
|
if body["query"].as_str().unwrap().contains("reviewState") {
|
|
return Ok(json!({"data":{"project":{"p7":{"reviewers":{
|
|
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":"UNREVIEWED"}}],
|
|
"pageInfo":{"hasNextPage":false}
|
|
}}}}}));
|
|
}
|
|
return if provider == Provider::Github {
|
|
Ok(json!({"data":{"repository":{"p7":{"additions":1,"deletions":2,
|
|
"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"viewer"}}]}}}}}))
|
|
} else {
|
|
Ok(json!({"data":{"project":{
|
|
"p7":{"diffStatsSummary":{"additions":1,"deletions":2}},
|
|
"approved":{"nodes":[{"iid":"7"}],"pageInfo":{"hasNextPage":false}}
|
|
}}}))
|
|
};
|
|
}
|
|
if url.contains("/7/reviews?") {
|
|
return Ok(if url.ends_with("page=1") {
|
|
json!([
|
|
{"id":10,"user":{"id":42},"state":"APPROVED","dismissed":false},
|
|
{"id":20,"user":{"id":42},"state":"REQUEST_REVIEW","dismissed":false}
|
|
])
|
|
} else {
|
|
json!([])
|
|
});
|
|
}
|
|
Ok(json!([{"number":7,"iid":7,"title":"Request again","created_at":"2024-01-01T00:00:00Z",
|
|
"state":if provider == Provider::Gitlab { "opened" } else { "open" },
|
|
"merged":false,"merged_at":null,"user":{"login":"author"},"author":{"username":"author"},
|
|
"requested_reviewers":[{"login":"viewer","id":42}],"reviewers":[{"username":"viewer"}],
|
|
"additions":1,"deletions":2}]))
|
|
}).unwrap();
|
|
assert_eq!(page.entries.len(), 1);
|
|
assert!(page.entries[0].approved && page.entries[0].review_requested);
|
|
assert!(page.entries[0].description.contains("+1 -2"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn draft_summaries_keep_open_closed_and_merged_states_distinct() {
|
|
for provider in [Provider::Github, Provider::Gitlab, Provider::Gitea] {
|
|
for (closed, merged, expected) in [
|
|
(false, false, "draft"),
|
|
(true, false, "closed"),
|
|
(true, true, "merged"),
|
|
] {
|
|
let mut value = json!({
|
|
"number": 7, "iid": 7, "title": "Unfinished work", "draft": true,
|
|
"state": if merged && provider == Provider::Gitlab { "merged" }
|
|
else if closed { "closed" }
|
|
else if provider == Provider::Gitlab { "opened" } else { "open" },
|
|
"merged": merged,
|
|
"merged_at": if merged { Some("2024-01-02T00:00:00Z") } else { None },
|
|
"user": null, "author": null, "created_at": "2024-01-01T00:00:00Z"
|
|
});
|
|
let entry = summary(provider, &value, Some((1, 2))).unwrap();
|
|
assert!(entry.draft);
|
|
assert_eq!(entry.closed, closed);
|
|
assert_eq!(
|
|
entry.description,
|
|
format!("deleted user · {expected} · +1 -2")
|
|
);
|
|
if !closed {
|
|
value["draft"] = json!(false);
|
|
assert!(!summary(provider, &value, None).unwrap().draft);
|
|
value.as_object_mut().unwrap().remove("draft");
|
|
assert!(!summary(provider, &value, None).unwrap().draft);
|
|
value["draft"] = json!("true");
|
|
assert!(summary(provider, &value, None).is_err());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pull_request_sizes_include_zero_and_preserve_unknown_statistics() {
|
|
for (url, provider) in [
|
|
("https://github.com/owner/repo", Provider::Github),
|
|
("https://github.enterprise/owner/repo", Provider::Github),
|
|
("https://gitlab.com/group/repo", Provider::Gitlab),
|
|
("https://codeberg.org/owner/repo", Provider::Gitea),
|
|
] {
|
|
let forge = parse(
|
|
url,
|
|
Some(match provider {
|
|
Provider::Github => "github",
|
|
Provider::Gitlab => "gitlab",
|
|
Provider::Gitea => "gitea",
|
|
}),
|
|
)
|
|
.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();
|
|
assert!(query.starts_with("query{"));
|
|
assert!(!query.contains("mutation"));
|
|
if provider == Provider::Github {
|
|
assert_eq!(
|
|
url,
|
|
if forge.host == "github.com" {
|
|
"https://api.github.com/graphql"
|
|
} else {
|
|
"https://github.enterprise/api/graphql"
|
|
}
|
|
);
|
|
assert!(query.contains("p7:pullRequest(number:7)"));
|
|
return Ok(json!({"data":{"repository":{
|
|
"p7":{"additions":123,"deletions":45},
|
|
"p8":{"additions":0,"deletions":0},
|
|
"p9":null
|
|
}}}));
|
|
}
|
|
assert_eq!(url, "https://gitlab.com/api/graphql");
|
|
assert!(query.contains("p7:mergeRequest(iid:\"7\")"));
|
|
return Ok(json!({"data":{"project":{
|
|
"p7":{"diffStatsSummary":{"additions":123,"deletions":45}},
|
|
"p8":{"diffStatsSummary":{"additions":0,"deletions":0}},
|
|
"p9":{"diffStatsSummary":null}
|
|
}}}));
|
|
}
|
|
Ok(Value::Array((7..=9).map(|id| json!({
|
|
"merged_at":null, "merged":false,
|
|
"number":id, "iid":id, "title":"Sized PR",
|
|
"state":if provider == Provider::Gitlab { "opened" } else { "open" },
|
|
"user":{"login":"author"}, "author":{"username":"author"},
|
|
"created_at":"2024-01-01T00:00:00Z",
|
|
"base":{"ref":"main"}, "head":{"ref":"topic"},
|
|
"target_branch":"main", "source_branch":"topic",
|
|
"additions":match id { 7 => Some(123), 8 => Some(0), _ => None },
|
|
"deletions":match id { 7 => Some(45), 8 => Some(0), _ => None }
|
|
})).collect()))
|
|
})
|
|
.unwrap();
|
|
let description = |id| {
|
|
&page
|
|
.entries
|
|
.iter()
|
|
.find(|entry| entry.number == id)
|
|
.unwrap()
|
|
.description
|
|
};
|
|
assert!(description(7).contains("open · +123 -45 · topic → main"));
|
|
assert!(description(8).contains("open · +0 -0 · topic → main"));
|
|
assert!(description(9).contains("size unavailable"));
|
|
assert!(!description(9).contains("+0 -0"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
|
|
for (provider, url, query) in [
|
|
(
|
|
Provider::Github,
|
|
"https://github.com/owner/repo",
|
|
"state=open&per_page=50&sort=created&direction=desc&page=3",
|
|
),
|
|
(
|
|
Provider::Gitlab,
|
|
"https://gitlab.com/owner/repo",
|
|
"state=opened&scope=all&per_page=50&order_by=created_at&sort=desc&page=3",
|
|
),
|
|
(
|
|
Provider::Gitea,
|
|
"https://codeberg.org/owner/repo",
|
|
"state=open&limit=50&page=3",
|
|
),
|
|
] {
|
|
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());
|
|
}
|
|
assert_eq!(url, format!("{}?{query}", forge.endpoint));
|
|
Ok(json!([
|
|
{"number": 1, "iid": 1, "title": "Older", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
|
"user": null, "author": null, "merged_at": null, "merged": false, "created_at": "2024-01-01T01:00:00+01:00"},
|
|
{"number": 2, "iid": 2, "title": "Newer", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
|
"user": {"login": "author"}, "author": {"username": "author"}, "merged_at": null, "merged": false,
|
|
"created_at": "2024-01-01T00:00:00.001Z", "diff_refs": null, "head": {"sha": "not-a-commit"}}
|
|
]))
|
|
}).unwrap();
|
|
assert_eq!(
|
|
page.entries.iter().map(|p| p.number).collect::<Vec<_>>(),
|
|
[2, 1]
|
|
);
|
|
assert!(page.entries[1].description.contains("deleted user"));
|
|
assert!(
|
|
page.has_more,
|
|
"A short, server-capped page is not exhaustion"
|
|
);
|
|
let empty = forge.page_with(false, 4, |_, _| Ok(json!([]))).unwrap();
|
|
assert!(empty.entries.is_empty() && !empty.has_more);
|
|
assert!(
|
|
forge
|
|
.page_with(false, 0, |_, _| panic!("Page zero must not request"))
|
|
.is_err()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
|
|
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
|
|
let mut all = Vec::new();
|
|
for page in 1..=3 {
|
|
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());
|
|
}
|
|
assert!(url.ends_with(&format!("&page={page}")));
|
|
let state = if url.contains("?state=closed&") {
|
|
"closed"
|
|
} else {
|
|
assert!(url.contains("?state=merged&"));
|
|
"merged"
|
|
};
|
|
states.push(state);
|
|
let (id, date) = match (state, page) {
|
|
("closed", 1) => (1, "2024-01-01T00:00:00Z"),
|
|
("merged", 1) => (3, "2024-03-01T00:00:00Z"),
|
|
("merged", 2) => (2, "2024-02-01T00:00:00Z"),
|
|
_ => return Ok(json!([])),
|
|
};
|
|
Ok(json!([{"iid": id, "title": state, "state": state,
|
|
"author": null, "created_at": date, "diff_refs": null}]))
|
|
})
|
|
.unwrap();
|
|
assert_eq!(states, ["closed", "merged"]);
|
|
assert_eq!(result.has_more, page != 3);
|
|
assert!(result.entries.iter().all(|p| p.closed));
|
|
if page == 1 {
|
|
assert_eq!(
|
|
result.entries.iter().map(|p| p.number).collect::<Vec<_>>(),
|
|
[3, 1]
|
|
);
|
|
}
|
|
// The second merged page still precedes the first closed page.
|
|
all.extend(result.entries);
|
|
}
|
|
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());
|
|
}
|
|
Ok(json!([{"iid": 7, "title": "Raced merge", "state": if url.contains("?state=merged&") { "merged" } else { "closed" },
|
|
"author": null, "created_at": "2024-01-01T00:00:00Z"}]))
|
|
}).unwrap();
|
|
assert_eq!(raced.entries.len(), 1);
|
|
assert!(raced.entries[0].description.contains("merged"));
|
|
}
|
|
|
|
#[test]
|
|
fn timestamps_normalize_offsets_precision_and_calendar_boundaries() {
|
|
for (input, expected) in [
|
|
(
|
|
"2024-03-01T00:30:12.5+01:00",
|
|
"2024-02-29T23:30:12.500000000Z",
|
|
),
|
|
(
|
|
"2023-12-31T23:30:00-01:00",
|
|
"2024-01-01T00:30:00.000000000Z",
|
|
),
|
|
(
|
|
"2024-01-01T00:00:00.000000001Z",
|
|
"2024-01-01T00:00:00.000000001Z",
|
|
),
|
|
] {
|
|
assert_eq!(created_at(&json!({"created_at": input})).unwrap(), expected);
|
|
}
|
|
for input in [
|
|
"",
|
|
"not a date",
|
|
"2023-02-29T00:00:00Z",
|
|
"2024-13-01T00:00:00Z",
|
|
"2024-01-01T24:00:00Z",
|
|
"2024-01-01T00:00:00",
|
|
"2024-01-01T00:00:00+24:00",
|
|
"2024-01-01T00:00:00.Z",
|
|
"2024-01-01T00:00:00.1234567890Z",
|
|
"0001-01-01T00:00:00+01:00",
|
|
"9999-12-31T23:00:00-02:00",
|
|
] {
|
|
assert!(
|
|
created_at(&json!({"created_at": input})).is_err(),
|
|
"{input}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn resolution_reads_only_selected_detail_and_exact_preserved_head() {
|
|
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();
|
|
let mut calls = Vec::new();
|
|
let pr = forge.resolve_with(7, |url| {
|
|
calls.push(url.to_owned());
|
|
if url == format!("{}/7", forge.endpoint) {
|
|
Ok(json!({"number": 7, "iid": 7, "title": "Selected", "state": if provider == Provider::Gitlab { "merged" } else { "closed" },
|
|
"merged_at": "2024-01-01T00:00:00Z", "merged": true, "user": null, "author": null,
|
|
"base": {"ref": "main", "sha": "1".repeat(40)}, "head": {"ref": "topic", "sha": "2".repeat(40)},
|
|
"merge_base": "3".repeat(40), "target_branch": "main", "source_branch": "topic",
|
|
"diff_refs": {"start_sha": null, "base_sha": "4".repeat(40), "head_sha": "5".repeat(40)},
|
|
"url": "https://evil.example/detail", "references": "https://evil.example/refs"}))
|
|
} else {
|
|
assert_eq!(url, format!("{}/git/refs/pull/7/head", forge.endpoint.strip_suffix("/pulls").unwrap()));
|
|
Ok(json!([{"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "6".repeat(40)}}]))
|
|
}
|
|
}).unwrap();
|
|
assert_eq!(calls.len(), if provider == Provider::Gitea { 2 } else { 1 });
|
|
let revisions = pr.revisions.unwrap();
|
|
assert_eq!(
|
|
revisions.base_sha,
|
|
match provider {
|
|
Provider::Github => "1",
|
|
Provider::Gitlab => "4",
|
|
Provider::Gitea => "3",
|
|
}
|
|
.repeat(40)
|
|
);
|
|
assert_eq!(
|
|
revisions.head_sha,
|
|
match provider {
|
|
Provider::Github => "2",
|
|
Provider::Gitlab => "5",
|
|
Provider::Gitea => "6",
|
|
}
|
|
.repeat(40)
|
|
);
|
|
assert!(
|
|
forge
|
|
.resolve_with(0, |_| panic!("Zero must not request"))
|
|
.is_err()
|
|
);
|
|
let mut calls = 0;
|
|
assert!(
|
|
forge
|
|
.resolve_with(7, |_| {
|
|
calls += 1;
|
|
Ok(json!({"number": 8, "iid": 8}))
|
|
})
|
|
.is_err()
|
|
);
|
|
assert_eq!(
|
|
calls, 1,
|
|
"Reject wrong detail identity before reading historical refs"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gitea_preserved_heads_reject_missing_ambiguous_or_foreign_refs() {
|
|
let reference =
|
|
json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "a".repeat(40)}});
|
|
assert_eq!(gitea_head(&reference, 7).unwrap(), "a".repeat(40));
|
|
assert_eq!(
|
|
gitea_head(&json!([reference.clone()]), 7).unwrap(),
|
|
"a".repeat(40)
|
|
);
|
|
for bad in [
|
|
json!([]),
|
|
json!([reference.clone(), reference.clone()]),
|
|
json!({"ref": "refs/pull/8/head", "object": reference["object"]}),
|
|
json!({"ref": "refs/pull/7/head", "object": {"type": "tag", "sha": "a".repeat(40)}}),
|
|
json!({"ref": "refs/pull/7/head", "object": {"type": "commit", "sha": "0".repeat(40)}}),
|
|
] {
|
|
assert!(gitea_head(&bad, 7).is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ssh_aliases_use_effective_hostname_and_preserve_user_matching() {
|
|
use std::{fs, io::Write, path::PathBuf};
|
|
struct Config(PathBuf);
|
|
impl Drop for Config {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_file(&self.0);
|
|
}
|
|
}
|
|
let path = std::env::temp_dir().join(format!("vamp-forge-ssh-{}.conf", std::process::id()));
|
|
let mut file = fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(&path)
|
|
.unwrap();
|
|
let config = Config(path);
|
|
file.write_all(b"Host gitea-personal\n HostName gitea.example.org\n Port 2222\nHost github-personal\n HostName ssh.github.com\nHost user-match\n Port 2222\nMatch originalhost user-match user git\n HostName gitlab.example.org\n").unwrap();
|
|
for (url, expected) in [
|
|
(
|
|
"git@gitea-personal:owner/repo.git",
|
|
"https://gitea.example.org/api/v1/repos/owner/repo/pulls",
|
|
),
|
|
(
|
|
"ssh://git@gitea-personal:2200/owner/repo.git",
|
|
"https://gitea.example.org/api/v1/repos/owner/repo/pulls",
|
|
),
|
|
(
|
|
"git@github-personal:owner/repo.git",
|
|
"https://api.github.com/repos/owner/repo/pulls",
|
|
),
|
|
(
|
|
"git@user-match:group/sub/repo.git",
|
|
"https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests",
|
|
),
|
|
] {
|
|
let forge = Forge::parse(url, None, |destination, port| {
|
|
let mut command = Command::new("ssh");
|
|
command.arg("-F").arg(&config.0);
|
|
ssh_hostname(command, destination, port, &AtomicBool::new(false))
|
|
})
|
|
.unwrap();
|
|
assert_eq!(forge.endpoint, expected);
|
|
}
|
|
assert!(
|
|
Forge::parse("git@alias:owner/repo", Some("gitea"), |_, _| Ok(
|
|
"evil.example/path".into()
|
|
))
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
Forge::parse(
|
|
"ssh://user:secret@alias/owner/repo",
|
|
Some("gitea"),
|
|
|_, _| { panic!("Password-bearing SSH URLs must be rejected before starting SSH") }
|
|
)
|
|
.is_err()
|
|
);
|
|
assert_eq!(
|
|
Forge::parse("https://github.com/owner/repo", None, |_, _| panic!(
|
|
"HTTPS must not invoke SSH"
|
|
))
|
|
.unwrap()
|
|
.host,
|
|
"github.com"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_urls_preserve_namespaces_and_keep_ssh_ports_out_of_https() {
|
|
let gitlab = 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!(
|
|
parse("git@github.com:owner/repo.git", None)
|
|
.unwrap()
|
|
.endpoint,
|
|
"https://api.github.com/repos/owner/repo/pulls"
|
|
);
|
|
assert_eq!(
|
|
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!(
|
|
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!(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)
|
|
);
|
|
if provider == Provider::Gitea {
|
|
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!(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()
|
|
);
|
|
}
|