| 1 | //! Pull requests opened from a branch of someone's fork of a repository |
| 2 | //! (`Pull::head_repo`): a repository whose `parent_id` chain reaches the one |
| 3 | //! it merges into. Not a working copy (`Pull::fork`, namespace `pulls`), |
| 4 | //! which g1t makes for one pull request and which knows its own target. |
| 5 | |
| 6 | use std::future::Future; |
| 7 | |
| 8 | use g1t_contracts::repos::{GetByIdArgs, Repo, RepoPath}; |
| 9 | use g1t_contracts::{FailureCode, Outcome, User}; |
| 10 | use worker::Result; |
| 11 | |
| 12 | use crate::Work; |
| 13 | |
| 14 | /// The open pull request from branch `?2` (of repository `?1` itself when |
| 15 | /// `?3` is null, else of the fork `?3`) into `?1`'s branch `?4` (null for |
| 16 | /// its default branch), if there is one: there is at most one. |
| 17 | pub(crate) const OPEN_FROM_BRANCH: &str = "SELECT number AS n FROM pulls |
| 18 | WHERE repo_id = ? AND source_branch = ? AND head_repo_id IS ? |
| 19 | AND fork_repo_id IS NULL AND status IN ('draft', 'open') |
| 20 | AND base_branch IS ?"; |
| 21 | |
| 22 | /// The pull requests whose change is on a branch a push to repository `?` |
| 23 | /// (bound twice) moved, branch `?`: those of the repository's own branch, |
| 24 | /// and those opened from it as a fork of another repository. |
| 25 | pub(crate) const ON_BRANCH: &str = "((repo_id = ? AND head_repo_id IS NULL) OR head_repo_id = ?) |
| 26 | AND source_branch = ? AND fork_repo_id IS NULL"; |
| 27 | |
| 28 | /// A fork renamed or transferred: the pull requests opened from it name it |
| 29 | /// where it is now (see `g1t_kit::transfer`; `?3` its workspace now, `?5` |
| 30 | /// its id, `?6` its name now). |
| 31 | pub(crate) const TRANSFERRED: &[&str] = &["UPDATE pulls SET head_namespace = ?3, head_name = ?6 WHERE head_repo_id = ?5"]; |
| 32 | |
| 33 | /// How many forks up a fork's parents are followed to find the repository |
| 34 | /// a pull request from it merges into. |
| 35 | pub(crate) const MAX_FORK_DEPTH: usize = 10; |
| 36 | |
| 37 | /// Whether following parents from `parent` (a repository's `parent_id`) |
| 38 | /// reaches `ancestor_id` within [`MAX_FORK_DEPTH`] steps. `parent_of` reads |
| 39 | /// a repository's own parent, `None` when it has none or cannot be read. |
| 40 | pub(crate) async fn reaches<F, Fut>(mut parent: Option<String>, ancestor_id: &str, mut parent_of: F) -> Result<bool> |
| 41 | where |
| 42 | F: FnMut(String) -> Fut, |
| 43 | Fut: Future<Output = Result<Option<String>>>, |
| 44 | { |
| 45 | for _ in 0..MAX_FORK_DEPTH { |
| 46 | let Some(id) = parent else { |
| 47 | return Ok(false); |
| 48 | }; |
| 49 | if id == ancestor_id { |
| 50 | return Ok(true); |
| 51 | } |
| 52 | parent = parent_of(id).await?; |
| 53 | } |
| 54 | Ok(false) |
| 55 | } |
| 56 | |
| 57 | /// How a pull request's source is named to people: `branch` for one of |
| 58 | /// the repository's own, `owner/name:branch` for a fork's. |
| 59 | pub(crate) fn source_label(head_repo: Option<&RepoPath>, branch: &str) -> String { |
| 60 | match head_repo { |
| 61 | Some(fork) => format!("{}/{}:{branch}", fork.namespace, fork.name), |
| 62 | None => branch.to_owned(), |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | /// Why a repository cannot hold a pull request's branch for `target`, if |
| 67 | /// it cannot: a working copy, or a repository that is not a fork of it. |
| 68 | /// `in_network`: whether its parents reach `target`. |
| 69 | pub(crate) fn head_repo_refusal(head: &Repo, target: &Repo, in_network: bool) -> Option<String> { |
| 70 | let name = format!("{}/{}", head.namespace, head.name); |
| 71 | let target_name = format!("{}/{}", target.namespace, target.name); |
| 72 | if head.fork_of.is_some() { |
| 73 | return Some(format!("{name} is a pull request's working copy. Open the pull request from a branch.")); |
| 74 | } |
| 75 | if !in_network { |
| 76 | return Some(format!("{name} is not a fork of {target_name}.")); |
| 77 | } |
| 78 | None |
| 79 | } |
| 80 | |
| 81 | impl Work { |
| 82 | /// Whether `repo` is a fork of `ancestor_id`, directly or through other |
| 83 | /// forks, as `actor` can read them. |
| 84 | pub(crate) async fn forked_from(&self, repo: &Repo, ancestor_id: &str, actor: &User) -> Result<bool> { |
| 85 | reaches(repo.parent_id.clone(), ancestor_id, |id| async move { |
| 86 | let found: Outcome<Repo> = |
| 87 | g1t_kit::call(&self.repos, "get_by_id", &GetByIdArgs { id, viewer: Some(actor.clone()) }).await?; |
| 88 | Ok(found.into_result().ok().and_then(|repo| repo.parent_id)) |
| 89 | }) |
| 90 | .await |
| 91 | } |
| 92 | |
| 93 | /// The fork named by `open_pull`'s `head_repo`, read as `actor`: |
| 94 | /// `None` when it is the target itself; refused unless it is a fork of |
| 95 | /// `target`. |
| 96 | pub(crate) async fn head_repo_for(&self, path: &RepoPath, target: &Repo, actor: &User) -> Result<Outcome<Option<Repo>>> { |
| 97 | let found: Outcome<Repo> = self.repo(path, &Some(actor.clone())).await?; |
| 98 | let head = match found { |
| 99 | Outcome::Ok(head) => head, |
| 100 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 101 | }; |
| 102 | if head.id == target.id { |
| 103 | return Ok(Outcome::Ok(None)); |
| 104 | } |
| 105 | let in_network = head.fork_of.is_none() && self.forked_from(&head, &target.id, actor).await?; |
| 106 | if let Some(message) = head_repo_refusal(&head, target, in_network) { |
| 107 | return Ok(Outcome::fail(FailureCode::Invalid, message)); |
| 108 | } |
| 109 | Ok(Outcome::Ok(Some(head))) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | #[cfg(test)] |
| 114 | mod tests { |
| 115 | use super::*; |
| 116 | use futures_util::FutureExt; |
| 117 | use std::collections::HashMap; |
| 118 | |
| 119 | fn walk(parents: &HashMap<&str, &str>, start: &str, ancestor: &str) -> bool { |
| 120 | let start = parents.get(start).map(|id| id.to_string()); |
| 121 | reaches(start, ancestor, |id| { |
| 122 | let parent = parents.get(id.as_str()).map(|id| id.to_string()); |
| 123 | async move { Ok(parent) } |
| 124 | }) |
| 125 | .now_or_never() |
| 126 | .unwrap() |
| 127 | .unwrap() |
| 128 | } |
| 129 | |
| 130 | #[test] |
| 131 | fn a_fork_reaches_its_parent_and_theirs() { |
| 132 | let parents = HashMap::from([("fork", "base"), ("fork_of_fork", "fork")]); |
| 133 | assert!(walk(&parents, "fork", "base")); |
| 134 | assert!(walk(&parents, "fork_of_fork", "base")); |
| 135 | assert!(walk(&parents, "fork_of_fork", "fork")); |
| 136 | assert!(!walk(&parents, "base", "fork"), "a parent is not its fork's fork"); |
| 137 | assert!(!walk(&parents, "fork", "other")); |
| 138 | } |
| 139 | |
| 140 | #[test] |
| 141 | fn a_loop_or_a_long_chain_stops() { |
| 142 | let looped = HashMap::from([("a", "b"), ("b", "a")]); |
| 143 | assert!(!walk(&looped, "a", "c")); |
| 144 | let names: Vec<String> = (0..=MAX_FORK_DEPTH + 1).map(|n| format!("r{n}")).collect(); |
| 145 | let chain: HashMap<&str, &str> = names.windows(2).map(|pair| (pair[0].as_str(), pair[1].as_str())).collect(); |
| 146 | assert!(walk(&chain, "r0", &names[MAX_FORK_DEPTH])); |
| 147 | assert!(!walk(&chain, "r0", &names[MAX_FORK_DEPTH + 1]), "too far up"); |
| 148 | } |
| 149 | |
| 150 | #[test] |
| 151 | fn a_fork_branch_is_named_with_its_fork() { |
| 152 | let fork = RepoPath { namespace: "ana".into(), name: "g1t".into() }; |
| 153 | assert_eq!(source_label(Some(&fork), "fix"), "ana/g1t:fix"); |
| 154 | assert_eq!(source_label(None, "fix"), "fix"); |
| 155 | } |
| 156 | |
| 157 | fn database() -> rusqlite::Connection { |
| 158 | let db = rusqlite::Connection::open_in_memory().unwrap(); |
| 159 | let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"); |
| 160 | let mut names: Vec<String> = std::fs::read_dir(dir) |
| 161 | .unwrap() |
| 162 | .map(|entry| entry.unwrap().file_name().into_string().unwrap()) |
| 163 | .filter(|name| name.ends_with(".sql")) |
| 164 | .collect(); |
| 165 | names.sort(); |
| 166 | for name in names { |
| 167 | db.execute_batch(&std::fs::read_to_string(format!("{dir}/{name}")).unwrap()).unwrap(); |
| 168 | } |
| 169 | // In flagon/g1t (rep_1): #1 from its own branch fix, #2 from ana's |
| 170 | // fork's (rep_2) branch fix, #3 from a working copy. |
| 171 | db.execute_batch( |
| 172 | "INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, created_at, updated_at, |
| 173 | status, source_branch, head_repo_id, head_namespace, head_name, fork_repo_id) |
| 174 | VALUES ('pr_1', 'rep_1', 1, 't', 'ana', 'external', 'usr_1', 'ana', '', '', 'open', 'fix', NULL, NULL, NULL, NULL), |
| 175 | ('pr_2', 'rep_1', 2, 't', 'ana', 'external', 'usr_1', 'ana', '', '', 'open', 'fix', 'rep_2', 'ana', 'g1t', NULL), |
| 176 | ('pr_3', 'rep_1', 3, 't', 'g1t', 'hosted', 'usr_1', 'ana', '', '', 'draft', NULL, NULL, NULL, NULL, 'rep_3');", |
| 177 | ) |
| 178 | .unwrap(); |
| 179 | db |
| 180 | } |
| 181 | |
| 182 | fn numbers(db: &rusqlite::Connection, sql: &str, params: impl rusqlite::Params) -> Vec<u32> { |
| 183 | let mut statement = db.prepare(sql).unwrap(); |
| 184 | statement.query_map(params, |row| row.get(0)).unwrap().map(Result::unwrap).collect() |
| 185 | } |
| 186 | |
| 187 | #[test] |
| 188 | fn one_open_pull_request_per_branch_of_each_repository() { |
| 189 | let db = database(); |
| 190 | let open = |head: Option<&str>| numbers(&db, OPEN_FROM_BRANCH, rusqlite::params!["rep_1", "fix", head, None::<String>]); |
| 191 | assert_eq!(open(None), vec![1], "the repository's own branch"); |
| 192 | assert_eq!(open(Some("rep_2")), vec![2], "the fork's branch of the same name is another"); |
| 193 | assert!(open(Some("rep_4")).is_empty(), "another fork's is free"); |
| 194 | } |
| 195 | |
| 196 | #[test] |
| 197 | fn a_push_moves_the_pull_requests_of_the_branch_it_moved() { |
| 198 | let db = database(); |
| 199 | let on = |repo: &str| numbers(&db, &format!("SELECT number FROM pulls WHERE {ON_BRANCH} ORDER BY number"), rusqlite::params![repo, repo, "fix"]); |
| 200 | assert_eq!(on("rep_1"), vec![1], "not the fork's branch of the same name"); |
| 201 | assert_eq!(on("rep_2"), vec![2], "a push to the fork moves the pull request into its parent"); |
| 202 | assert!(on("rep_3").is_empty(), "a working copy is moved by its default branch"); |
| 203 | } |
| 204 | |
| 205 | fn repo(id: &str, namespace: &str) -> Repo { |
| 206 | serde_json::from_value(serde_json::json!({ |
| 207 | "id": id, "namespace": namespace, "name": "g1t", "isPrivate": false, |
| 208 | "ownerId": "usr_1", "defaultBranch": "main", "forkOf": null, "protected": false, |
| 209 | "createdAt": "", "updatedAt": "" |
| 210 | })) |
| 211 | .unwrap() |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn only_forks_hold_a_pull_requests_branch() { |
| 216 | let target = repo("rep_1", "flagon"); |
| 217 | let fork = repo("rep_2", "ana"); |
| 218 | assert_eq!(head_repo_refusal(&fork, &target, true), None); |
| 219 | assert_eq!(head_repo_refusal(&fork, &target, false).as_deref(), Some("ana/g1t is not a fork of flagon/g1t.")); |
| 220 | let mut copy = repo("rep_3", "pulls"); |
| 221 | copy.fork_of = Some("rep_1".into()); |
| 222 | assert!(head_repo_refusal(©, &target, true).unwrap().contains("working copy")); |
| 223 | } |
| 224 | } |