Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
224 linesCodeBlameRaw
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
6use std::future::Future;
7
8use g1t_contracts::repos::{GetByIdArgs, Repo, RepoPath};
9use g1t_contracts::{FailureCode, Outcome, User};
10use worker::Result;
11
12use 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.
17pub(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.
25pub(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).
31pub(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.
35pub(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.
40pub(crate) async fn reaches<F, Fut>(mut parent: Option<String>, ancestor_id: &str, mut parent_of: F) -> Result<bool>
41where
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.
59pub(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`.
69pub(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
81impl 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)]
114mod 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(&copy, &target, true).unwrap().contains("working copy"));
223 }
224}