use super::*; use serde_json::json; fn parse(url: &str, provider: Option<&str>) -> Result { 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::>(), [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::>(), [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::>(), [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::>(), [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::>(), [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..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() ); }