upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2023-10-01 00:00:00 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2023-10-01 00:00:00 +0100
commit6e9245542f070c39a1975f0d53d88913c4ac667d (patch)
tree835c1d3db05f76f437c5d8ebc5591f1796cdab60 /src
parentaa48a626c08cec353d5563a8831239d2e69c9f3d (diff)
feat(prs-create) find commits and create events
- identify commits - create pull request event - create patch events
Diffstat (limited to 'src')
-rw-r--r--src/cli_interactor.rs27
-rw-r--r--src/git.rs476
-rw-r--r--src/main.rs4
-rw-r--r--src/sub_commands/mod.rs1
-rw-r--r--src/sub_commands/prs/create.rs371
-rw-r--r--src/sub_commands/prs/mod.rs22
6 files changed, 900 insertions, 1 deletions
diff --git a/src/cli_interactor.rs b/src/cli_interactor.rs
index d7de087..cf6e3d0 100644
--- a/src/cli_interactor.rs
+++ b/src/cli_interactor.rs
@@ -1,5 +1,5 @@
1use anyhow::Result; 1use anyhow::Result;
2use dialoguer::{theme::ColorfulTheme, Input, Password}; 2use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password};
3#[cfg(test)] 3#[cfg(test)]
4use mockall::*; 4use mockall::*;
5 5
@@ -12,6 +12,7 @@ pub struct Interactor {
12pub trait InteractorPrompt { 12pub trait InteractorPrompt {
13 fn input(&self, parms: PromptInputParms) -> Result<String>; 13 fn input(&self, parms: PromptInputParms) -> Result<String>;
14 fn password(&self, parms: PromptPasswordParms) -> Result<String>; 14 fn password(&self, parms: PromptPasswordParms) -> Result<String>;
15 fn confirm(&self, params: PromptConfirmParms) -> Result<bool>;
15} 16}
16impl InteractorPrompt for Interactor { 17impl InteractorPrompt for Interactor {
17 fn input(&self, parms: PromptInputParms) -> Result<String> { 18 fn input(&self, parms: PromptInputParms) -> Result<String> {
@@ -29,6 +30,13 @@ impl InteractorPrompt for Interactor {
29 let pass: String = p.interact()?; 30 let pass: String = p.interact()?;
30 Ok(pass) 31 Ok(pass)
31 } 32 }
33 fn confirm(&self, params: PromptConfirmParms) -> Result<bool> {
34 let confirm: bool = Confirm::with_theme(&self.theme)
35 .with_prompt(params.prompt)
36 .default(params.default)
37 .interact()?;
38 Ok(confirm)
39 }
32} 40}
33 41
34#[derive(Default)] 42#[derive(Default)]
@@ -59,3 +67,20 @@ impl PromptPasswordParms {
59 self 67 self
60 } 68 }
61} 69}
70
71#[derive(Default)]
72pub struct PromptConfirmParms {
73 pub prompt: String,
74 pub default: bool,
75}
76
77impl PromptConfirmParms {
78 pub fn with_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
79 self.prompt = prompt.into();
80 self
81 }
82 pub fn with_default(mut self, default: bool) -> Self {
83 self.default = default;
84 self
85 }
86}
diff --git a/src/git.rs b/src/git.rs
new file mode 100644
index 0000000..ddbc646
--- /dev/null
+++ b/src/git.rs
@@ -0,0 +1,476 @@
1use std::env::current_dir;
2#[cfg(test)]
3use std::path::PathBuf;
4
5use anyhow::{bail, Context, Result};
6use git2::{Oid, Revwalk};
7use nostr::prelude::{sha1::Hash as Sha1Hash, Hash};
8
9pub struct Repo {
10 git_repo: git2::Repository,
11}
12
13impl Repo {
14 pub fn discover() -> Result<Self> {
15 Ok(Self {
16 git_repo: git2::Repository::discover(current_dir()?)?,
17 })
18 }
19 #[cfg(test)]
20 pub fn from_path(path: &PathBuf) -> Result<Self> {
21 Ok(Self {
22 git_repo: git2::Repository::open(path)?,
23 })
24 }
25}
26
27// pub type CommitId = [u8; 7];
28// pub type Sha1 = [u8; 20];
29
30pub trait RepoActions {
31 fn get_local_branch_names(&self) -> Result<Vec<String>>;
32 fn get_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)>;
33 fn get_tip_of_local_branch(&self, branch_name: &str) -> Result<Sha1Hash>;
34 fn get_root_commit(&self, branch_name: &str) -> Result<Sha1Hash>;
35 fn get_head_commit(&self) -> Result<Sha1Hash>;
36 fn get_commit_parent(&self, commit: &Sha1Hash) -> Result<Sha1Hash>;
37 fn get_commits_ahead_behind(
38 &self,
39 base_commit: &Sha1Hash,
40 latest_commit: &Sha1Hash,
41 ) -> Result<(Vec<Sha1Hash>, Vec<Sha1Hash>)>;
42 fn make_patch_from_commit(&self, commit: &Sha1Hash) -> Result<String>;
43}
44
45impl RepoActions for Repo {
46 fn get_main_or_master_branch(&self) -> Result<(&str, Sha1Hash)> {
47 let main_branch_name = {
48 let local_branches = self
49 .get_local_branch_names()
50 .context("cannot find any local branches")?;
51 if local_branches.contains(&"main".to_string()) {
52 "main"
53 } else if local_branches.contains(&"master".to_string()) {
54 "master"
55 } else {
56 bail!("no main or master branch locally in this git repository to initiate from",)
57 }
58 };
59
60 let tip = self
61 .get_tip_of_local_branch(main_branch_name)
62 .context(format!(
63 "branch {main_branch_name} was listed as a local branch but cannot get its tip commit id",
64 ))?;
65
66 Ok((main_branch_name, tip))
67 }
68
69 fn get_local_branch_names(&self) -> Result<Vec<String>> {
70 let local_branches = self
71 .git_repo
72 .branches(Some(git2::BranchType::Local))
73 .context("getting GitRepo branches should not error even for a blank repository")?;
74
75 let mut branch_names = vec![];
76
77 for iter in local_branches {
78 let branch = iter?.0;
79 if let Some(name) = branch.name()? {
80 branch_names.push(name.to_string());
81 }
82 }
83 Ok(branch_names)
84 }
85
86 fn get_tip_of_local_branch(&self, branch_name: &str) -> Result<Sha1Hash> {
87 let branch = self
88 .git_repo
89 .find_branch(branch_name, git2::BranchType::Local)
90 .context(format!("cannot find branch {branch_name}"))?;
91 Ok(oid_to_sha1(&branch.into_reference().peel_to_commit()?.id()))
92 }
93
94 fn get_root_commit(&self, branch_name: &str) -> Result<Sha1Hash> {
95 let tip = self.get_tip_of_local_branch(branch_name)?;
96 let mut revwalk = self
97 .git_repo
98 .revwalk()
99 .context("revwalk should be created from git repo")?;
100 revwalk
101 .push(sha1_to_oid(&tip)?)
102 .context("revwalk should accept tip oid")?;
103 Ok(oid_to_sha1(
104 &revwalk
105 .last()
106 .context("revwalk from tip should be at least contain the tip oid")?
107 .context("revwalk iter from branch tip should not result in an error")?,
108 ))
109 }
110
111 fn get_head_commit(&self) -> Result<Sha1Hash> {
112 let head = self
113 .git_repo
114 .head()
115 .context("failed to get git repo head")?;
116 let oid = head.peel_to_commit()?.id();
117 Ok(oid_to_sha1(&oid))
118 }
119
120 fn get_commit_parent(&self, commit: &Sha1Hash) -> Result<Sha1Hash> {
121 let parent_oid = self
122 .git_repo
123 .find_commit(sha1_to_oid(commit)?)
124 .context(format!("could not find commit {commit}"))?
125 .parent_id(0)
126 .context(format!("could not find parent of commit {commit}"))?;
127 Ok(oid_to_sha1(&parent_oid))
128 }
129
130 fn make_patch_from_commit(&self, commit: &Sha1Hash) -> Result<String> {
131 let c = self
132 .git_repo
133 .find_commit(Oid::from_bytes(commit.as_byte_array()).context(format!(
134 "failed to convert commit_id format for {}",
135 &commit
136 ))?)
137 .context(format!("failed to find commit {}", &commit))?;
138 let patch = git2::Email::from_commit(&c, &mut git2::EmailCreateOptions::default())
139 .context(format!("failed to create patch from commit {}", &commit))?;
140
141 Ok(std::str::from_utf8(patch.as_slice())
142 .context("patch content could not be converted to a utf8 string")?
143 .to_owned())
144 }
145
146 fn get_commits_ahead_behind(
147 &self,
148 base_commit: &Sha1Hash,
149 latest_commit: &Sha1Hash,
150 ) -> Result<(Vec<Sha1Hash>, Vec<Sha1Hash>)> {
151 let mut ahead: Vec<Sha1Hash> = vec![];
152 let mut behind: Vec<Sha1Hash> = vec![];
153
154 let get_revwalk = |commit: &Sha1Hash| -> Result<Revwalk> {
155 let mut revwalk = self
156 .git_repo
157 .revwalk()
158 .context("revwalk should be created from git repo")?;
159 revwalk
160 .push(sha1_to_oid(commit)?)
161 .context("revwalk should accept commit oid")?;
162 Ok(revwalk)
163 };
164
165 // scan through the base commit ancestory until a common ancestor is found
166 let most_recent_shared_commit = match get_revwalk(base_commit)
167 .context("failed to get revwalk for base_commit")?
168 .find(|base_res| {
169 let base_oid = base_res.as_ref().unwrap();
170
171 if get_revwalk(latest_commit)
172 .unwrap()
173 .any(|latest_res| base_oid.eq(latest_res.as_ref().unwrap()))
174 {
175 true
176 } else {
177 // add commits not found in latest ancestory to 'behind' vector
178 behind.push(oid_to_sha1(base_oid));
179 false
180 }
181 }) {
182 None => {
183 bail!(format!(
184 "{} is not an ancestor of {}",
185 latest_commit, base_commit
186 ));
187 }
188 Some(res) => res.context("revwalk failed to reveal commit")?,
189 };
190
191 // scan through the latest commits until shared commit is reached
192 get_revwalk(latest_commit)
193 .context("failed to get revwalk for latest_commit")?
194 .any(|latest_res| {
195 let latest_oid = latest_res.as_ref().unwrap();
196 if latest_oid.eq(&most_recent_shared_commit) {
197 true
198 } else {
199 // add commits not found in base to 'ahead' vector
200 ahead.push(oid_to_sha1(latest_oid));
201 false
202 }
203 });
204 Ok((ahead, behind))
205 }
206}
207
208fn oid_to_u8_20_bytes(oid: &Oid) -> [u8; 20] {
209 let b = oid.as_bytes();
210 [
211 b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13],
212 b[14], b[15], b[16], b[17], b[18], b[19],
213 ]
214}
215
216// fn oid_to_shorthand_string(oid: Oid) -> Result<String> {
217// let binding = oid.to_string();
218// let b = binding.as_bytes();
219// String::from_utf8(vec![b[0], b[1], b[2], b[3], b[4], b[5], b[6]])
220// .context("oid should always start with 7 u8 btyes of utf8")
221// }
222
223// fn oid_to_sha1_string(oid: Oid) -> Result<String> {
224// let b = oid.as_bytes();
225// String::from_utf8(vec![
226// b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10],
227// b[11], b[12], b[13], b[14], b[15], b[16], b[17], b[18], b[19],
228// ])
229// .context("oid should contain 20 u8 btyes of utf8")
230// }
231
232// git2 Oid object to Sha1Hash
233pub fn oid_to_sha1(oid: &Oid) -> Sha1Hash {
234 Sha1Hash::from_byte_array(oid_to_u8_20_bytes(oid))
235}
236
237/// `Sha1Hash` to git2 `Oid` object
238fn sha1_to_oid(hash: &Sha1Hash) -> Result<Oid> {
239 Oid::from_bytes(hash.as_byte_array()).context("Sha1Hash bytes failed to produce a valid Oid")
240}
241
242#[cfg(test)]
243mod tests {
244 use test_utils::git::GitTestRepo;
245
246 use super::*;
247
248 mod make_patch_from_commit {
249 use super::*;
250 #[test]
251 fn simple_patch_matches_string() -> Result<()> {
252 let test_repo = GitTestRepo::default();
253 let oid = test_repo.populate()?;
254
255 let git_repo = Repo::from_path(&test_repo.dir)?;
256
257 assert_eq!(
258 "\
259 From 431b84edc0d2fa118d63faa3c2db9c73d630a5ae Mon Sep 17 00:00:00 2001\n\
260 From: Joe Bloggs <joe.bloggs@pm.me>\n\
261 Date: Thu, 1 Jan 1970 00:00:00 +0000\n\
262 Subject: [PATCH] add t2.md\n\
263 \n\
264 ---\n \
265 t2.md | 1 +\n \
266 1 file changed, 1 insertion(+)\n \
267 create mode 100644 t2.md\n\
268 \n\
269 diff --git a/t2.md b/t2.md\n\
270 new file mode 100644\n\
271 index 0000000..a66525d\n\
272 --- /dev/null\n\
273 +++ b/t2.md\n\
274 @@ -0,0 +1 @@\n\
275 +some content1\n\\ \
276 No newline at end of file\n\
277 --\n\
278 libgit2 1.7.1\n\
279 \n\
280 ",
281 git_repo.make_patch_from_commit(&oid_to_sha1(&oid))?,
282 );
283 Ok(())
284 }
285 }
286
287 mod get_main_or_master_branch {
288
289 use super::*;
290 mod returns_main {
291 use super::*;
292 #[test]
293 fn when_it_exists() -> Result<()> {
294 let test_repo = GitTestRepo::new("main")?;
295 let main_oid = test_repo.populate()?;
296 let git_repo = Repo::from_path(&test_repo.dir)?;
297 let (name, commit_hash) = git_repo.get_main_or_master_branch()?;
298 assert_eq!(name, "main");
299 assert_eq!(commit_hash, oid_to_sha1(&main_oid));
300 Ok(())
301 }
302
303 #[test]
304 fn when_it_exists_and_other_branch_checkedout() -> Result<()> {
305 let test_repo = GitTestRepo::new("main")?;
306 let main_oid = test_repo.populate()?;
307 test_repo.create_branch("feature")?;
308 test_repo.checkout("feature")?;
309 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
310 let feature_oid = test_repo.stage_and_commit("add t3.md")?;
311
312 let git_repo = Repo::from_path(&test_repo.dir)?;
313 let (name, commit_hash) = git_repo.get_main_or_master_branch()?;
314 assert_eq!(name, "main");
315 assert_eq!(commit_hash, oid_to_sha1(&main_oid));
316 assert_ne!(commit_hash, oid_to_sha1(&feature_oid));
317 Ok(())
318 }
319
320 #[test]
321 fn when_exists_even_if_master_is_checkedout() -> Result<()> {
322 let test_repo = GitTestRepo::new("main")?;
323 let main_oid = test_repo.populate()?;
324 test_repo.create_branch("master")?;
325 test_repo.checkout("master")?;
326 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
327 let master_oid = test_repo.stage_and_commit("add t3.md")?;
328
329 let git_repo = Repo::from_path(&test_repo.dir)?;
330 let (name, commit_hash) = git_repo.get_main_or_master_branch()?;
331 assert_eq!(name, "main");
332 assert_eq!(commit_hash, oid_to_sha1(&main_oid));
333 assert_ne!(commit_hash, oid_to_sha1(&master_oid));
334 Ok(())
335 }
336 }
337
338 #[test]
339 fn returns_master_if_exists_and_main_doesnt() -> Result<()> {
340 let test_repo = GitTestRepo::new("master")?;
341 let master_oid = test_repo.populate()?;
342 test_repo.create_branch("feature")?;
343 test_repo.checkout("feature")?;
344 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
345 let feature_oid = test_repo.stage_and_commit("add t3.md")?;
346
347 let git_repo = Repo::from_path(&test_repo.dir)?;
348 let (name, commit_hash) = git_repo.get_main_or_master_branch()?;
349 assert_eq!(name, "master");
350 assert_eq!(commit_hash, oid_to_sha1(&master_oid));
351 assert_ne!(commit_hash, oid_to_sha1(&feature_oid));
352 Ok(())
353 }
354 #[test]
355 fn returns_error_if_no_main_or_master() -> Result<()> {
356 let test_repo = GitTestRepo::new("feature")?;
357 test_repo.populate()?;
358 let git_repo = Repo::from_path(&test_repo.dir)?;
359 assert!(git_repo.get_main_or_master_branch().is_err());
360 Ok(())
361 }
362 }
363
364 mod get_commits_ahead_behind {
365 use super::*;
366 mod returns_main {
367 use super::*;
368
369 #[test]
370 fn when_on_same_commit_return_empty() -> Result<()> {
371 let test_repo = GitTestRepo::default();
372 let oid = test_repo.populate()?;
373 // create feature branch
374 test_repo.create_branch("feature")?;
375 test_repo.checkout("feature")?;
376
377 let git_repo = Repo::from_path(&test_repo.dir)?;
378
379 let (ahead, behind) =
380 git_repo.get_commits_ahead_behind(&oid_to_sha1(&oid), &oid_to_sha1(&oid))?;
381 assert_eq!(ahead, vec![]);
382 assert_eq!(behind, vec![]);
383 Ok(())
384 }
385
386 #[test]
387 fn when_2_commit_behind() -> Result<()> {
388 let test_repo = GitTestRepo::default();
389 test_repo.populate()?;
390 // create feature branch
391 test_repo.create_branch("feature")?;
392 let feature_oid = test_repo.checkout("feature")?;
393 // checkout main and add 2 commits
394 test_repo.checkout("main")?;
395 std::fs::write(test_repo.dir.join("t5.md"), "some content")?;
396 let behind_1_oid = test_repo.stage_and_commit("add t5.md")?;
397 std::fs::write(test_repo.dir.join("t6.md"), "some content")?;
398 let behind_2_oid = test_repo.stage_and_commit("add t6.md")?;
399
400 let git_repo = Repo::from_path(&test_repo.dir)?;
401
402 let (ahead, behind) = git_repo.get_commits_ahead_behind(
403 &oid_to_sha1(&behind_2_oid),
404 &oid_to_sha1(&feature_oid),
405 )?;
406 assert_eq!(ahead, vec![]);
407 assert_eq!(
408 behind,
409 vec![oid_to_sha1(&behind_2_oid), oid_to_sha1(&behind_1_oid),],
410 );
411 Ok(())
412 }
413
414 #[test]
415 fn when_2_commit_ahead() -> Result<()> {
416 let test_repo = GitTestRepo::default();
417 let main_oid = test_repo.populate()?;
418 // create feature branch and add 2 commits
419 test_repo.create_branch("feature")?;
420 test_repo.checkout("feature")?;
421 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
422 let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?;
423 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
424 let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?;
425
426 let git_repo = Repo::from_path(&test_repo.dir)?;
427
428 let (ahead, behind) = git_repo.get_commits_ahead_behind(
429 &oid_to_sha1(&main_oid),
430 &oid_to_sha1(&ahead_2_oid),
431 )?;
432 assert_eq!(
433 ahead,
434 vec![oid_to_sha1(&ahead_2_oid), oid_to_sha1(&ahead_1_oid),],
435 );
436 assert_eq!(behind, vec![]);
437 Ok(())
438 }
439
440 #[test]
441 fn when_2_commit_ahead_and_2_commits_behind() -> Result<()> {
442 let test_repo = GitTestRepo::default();
443 test_repo.populate()?;
444 // create feature branch and add 2 commits
445 test_repo.create_branch("feature")?;
446 test_repo.checkout("feature")?;
447 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
448 let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?;
449 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
450 let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?;
451 // checkout main and add 2 commits
452 test_repo.checkout("main")?;
453 std::fs::write(test_repo.dir.join("t5.md"), "some content")?;
454 let behind_1_oid = test_repo.stage_and_commit("add t5.md")?;
455 std::fs::write(test_repo.dir.join("t6.md"), "some content")?;
456 let behind_2_oid = test_repo.stage_and_commit("add t6.md")?;
457
458 let git_repo = Repo::from_path(&test_repo.dir)?;
459
460 let (ahead, behind) = git_repo.get_commits_ahead_behind(
461 &oid_to_sha1(&behind_2_oid),
462 &oid_to_sha1(&ahead_2_oid),
463 )?;
464 assert_eq!(
465 ahead,
466 vec![oid_to_sha1(&ahead_2_oid), oid_to_sha1(&ahead_1_oid)],
467 );
468 assert_eq!(
469 behind,
470 vec![oid_to_sha1(&behind_2_oid), oid_to_sha1(&behind_1_oid)],
471 );
472 Ok(())
473 }
474 }
475 }
476}
diff --git a/src/main.rs b/src/main.rs
index e6eac32..5094c11 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,6 +6,7 @@ use clap::{Parser, Subcommand};
6 6
7mod cli_interactor; 7mod cli_interactor;
8mod config; 8mod config;
9mod git;
9mod key_handling; 10mod key_handling;
10mod login; 11mod login;
11mod sub_commands; 12mod sub_commands;
@@ -28,11 +29,14 @@ pub struct Cli {
28enum Commands { 29enum Commands {
29 /// save encrypted nsec for future use 30 /// save encrypted nsec for future use
30 Login(sub_commands::login::SubCommandArgs), 31 Login(sub_commands::login::SubCommandArgs),
32 /// create and issue Prs
33 Prs(sub_commands::prs::SubCommandArgs),
31} 34}
32 35
33fn main() -> Result<()> { 36fn main() -> Result<()> {
34 let cli = Cli::parse(); 37 let cli = Cli::parse();
35 match &cli.command { 38 match &cli.command {
36 Commands::Login(args) => sub_commands::login::launch(&cli, args), 39 Commands::Login(args) => sub_commands::login::launch(&cli, args),
40 Commands::Prs(args) => sub_commands::prs::launch(&cli, args),
37 } 41 }
38} 42}
diff --git a/src/sub_commands/mod.rs b/src/sub_commands/mod.rs
index 320cbbb..3c3da1d 100644
--- a/src/sub_commands/mod.rs
+++ b/src/sub_commands/mod.rs
@@ -1 +1,2 @@
1pub mod login; 1pub mod login;
2pub mod prs;
diff --git a/src/sub_commands/prs/create.rs b/src/sub_commands/prs/create.rs
new file mode 100644
index 0000000..dd32c65
--- /dev/null
+++ b/src/sub_commands/prs/create.rs
@@ -0,0 +1,371 @@
1use anyhow::{bail, Context, Result};
2use nostr::{prelude::sha1::Hash as Sha1Hash, EventBuilder, Marker, Tag, TagKind};
3
4use crate::{
5 cli_interactor::{Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms},
6 git::{Repo, RepoActions},
7 login, Cli,
8};
9
10#[derive(Debug, clap::Args)]
11pub struct SubCommandArgs {
12 #[clap(short, long)]
13 /// title of pull request (defaults to first line of first commit)
14 title: Option<String>,
15 #[clap(short, long)]
16 /// optional description
17 description: Option<String>,
18 #[clap(long)]
19 /// branch to get changes from (defaults to head)
20 from_branch: Option<String>,
21 #[clap(long)]
22 /// destination branch (defaults to main or master)
23 to_branch: Option<String>,
24}
25
26pub fn launch(
27 cli_args: &Cli,
28 _pr_args: &super::SubCommandArgs,
29 args: &SubCommandArgs,
30) -> Result<()> {
31 let git_repo = Repo::discover().context("cannot find a git repository")?;
32
33 let (from_branch, to_branch, ahead, behind) =
34 identify_ahead_behind(&git_repo, &args.from_branch, &args.to_branch)?;
35
36 if ahead.is_empty() {
37 bail!(format!(
38 "'{from_branch}' is 0 commits ahead of '{to_branch}' so no patches were created"
39 ));
40 }
41
42 if behind.is_empty() {
43 println!(
44 "creating patch for {} commits from '{from_branch}' that can be merged into '{to_branch}'",
45 ahead.len(),
46 );
47 } else {
48 if !Interactor::default().confirm(
49 PromptConfirmParms::default()
50 .with_prompt(
51 format!(
52 "'{from_branch}' is {} commits behind '{to_branch}' and {} ahead. Consider rebasing before sending patches. Proceed anyway?",
53 behind.len(),
54 ahead.len(),
55 )
56 )
57 .with_default(false)
58 ).context("failed to get confirmation response from interactor confirm")? {
59 bail!("aborting so branch can be rebased");
60 }
61 println!(
62 "creating patch for {} commit{} from '{from_branch}' that {} {} behind '{to_branch}'",
63 ahead.len(),
64 if ahead.len() > 1 { "s" } else { "" },
65 if ahead.len() > 1 { "are" } else { "is" },
66 behind.len(),
67 );
68 }
69
70 let title = match &args.title {
71 Some(t) => t.clone(),
72 None => Interactor::default().input(PromptInputParms::default().with_prompt("title"))?,
73 };
74
75 let description = match &args.description {
76 Some(t) => t.clone(),
77 None => Interactor::default()
78 .input(PromptInputParms::default().with_prompt("description (Optional)"))?,
79 };
80
81 let root_commit = git_repo
82 .get_root_commit(to_branch.as_str())
83 .context("failed to get root commit of the repository")?;
84 // create PR event
85
86 let keys = login::launch(&cli_args.nsec, &cli_args.password)?;
87
88 let pr_event = EventBuilder::new(
89 nostr::event::Kind::Custom(318),
90 format!("{title}\r\n\r\n{description}"),
91 &[Tag::Hashtag(format!("r-{root_commit}"))],
92 // TODO: suggested branch name
93 // Tag::Generic(
94 // TagKind::Custom("suggested-branch-name".to_string()),
95 // vec![],
96 // ),
97 // TODO: add Repo event as root
98 // TODO: people tag maintainers
99 // TODO: add relay tags
100 )
101 .to_event(&keys)
102 .context("failed to create pr event")?;
103
104 let mut patch_events = vec![];
105 for commit in &ahead {
106 let commit_parent = git_repo
107 .get_commit_parent(commit)
108 .context("failed to create patch event")?;
109 patch_events.push(
110 EventBuilder::new(
111 nostr::event::Kind::Custom(317),
112 git_repo
113 .make_patch_from_commit(commit)
114 .context(format!("cannot make patch for commit {commit}"))?,
115 &[
116 Tag::Hashtag(format!("r-{root_commit}")),
117 Tag::Hashtag(commit.to_string()),
118 Tag::Hashtag(commit_parent.to_string()),
119 Tag::Event(
120 pr_event.id,
121 None, // TODO: add relay
122 Some(Marker::Root),
123 ),
124 Tag::Generic(
125 TagKind::Custom("commit".to_string()),
126 vec![commit.to_string()],
127 ),
128 Tag::Generic(
129 TagKind::Custom("parent-commit".to_string()),
130 vec![commit_parent.to_string()],
131 ),
132 // TODO: add Repo event tags
133 // TODO: people tag maintainers
134 // TODO: add relay tags
135 ],
136 )
137 .to_event(&keys),
138 );
139 }
140
141 // TODO check if there is already a similarly named PR
142 // TODO connect to relays and post
143
144 Ok(())
145}
146
147// TODO
148// - find profile
149// - file relays
150// - find repo events
151// -
152
153/**
154 * returns `(from_branch,to_branch,ahead,behind)`
155 */
156fn identify_ahead_behind(
157 git_repo: &Repo,
158 from_branch: &Option<String>,
159 to_branch: &Option<String>,
160) -> Result<(String, String, Vec<Sha1Hash>, Vec<Sha1Hash>)> {
161 let (from_branch, from_tip) = match from_branch {
162 Some(name) => (
163 name.to_string(),
164 git_repo
165 .get_tip_of_local_branch(name)
166 .context(format!("cannot find from_branch '{name}'"))?,
167 ),
168 None => (
169 "head".to_string(),
170 git_repo
171 .get_head_commit()
172 .context("failed to get head commit")
173 .context(
174 "checkout a commit or specify a from_branch. head does not reveal a commit",
175 )?,
176 ),
177 };
178
179 let (to_branch, to_tip) = match to_branch {
180 Some(name) => (
181 name.to_string(),
182 git_repo
183 .get_tip_of_local_branch(name)
184 .context(format!("cannot find to_branch '{name}'"))?,
185 ),
186 None => {
187 let (name, commit) = git_repo
188 .get_main_or_master_branch()
189 .context("a destination branch (to_branch) is not specified and the defaults (main or master) do not exist")?;
190 (name.to_string(), commit)
191 }
192 };
193
194 match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) {
195 Err(e) => {
196 if e.to_string().contains("is not an ancestor of") {
197 return Err(e).context(format!(
198 "'{from_branch}' is not branched from '{to_branch}'"
199 ));
200 }
201 Err(e).context(format!(
202 "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'"
203 ))
204 }
205 Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)),
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use test_utils::git::GitTestRepo;
212
213 use super::*;
214 mod identify_ahead_behind {
215
216 use super::*;
217 use crate::git::oid_to_sha1;
218
219 #[test]
220 fn when_from_branch_doesnt_exist_return_error() -> Result<()> {
221 let test_repo = GitTestRepo::default();
222 let git_repo = Repo::from_path(&test_repo.dir)?;
223
224 test_repo.populate()?;
225 let branch_name = "doesnt_exist";
226 assert_eq!(
227 identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None)
228 .unwrap_err()
229 .to_string(),
230 format!("cannot find from_branch '{}'", &branch_name),
231 );
232 Ok(())
233 }
234
235 #[test]
236 fn when_to_branch_doesnt_exist_return_error() -> Result<()> {
237 let test_repo = GitTestRepo::default();
238 let git_repo = Repo::from_path(&test_repo.dir)?;
239
240 test_repo.populate()?;
241 let branch_name = "doesnt_exist";
242 assert_eq!(
243 identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string()))
244 .unwrap_err()
245 .to_string(),
246 format!("cannot find to_branch '{}'", &branch_name),
247 );
248 Ok(())
249 }
250
251 #[test]
252 fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> {
253 let test_repo = GitTestRepo::new("notmain")?;
254 let git_repo = Repo::from_path(&test_repo.dir)?;
255
256 test_repo.populate()?;
257
258 assert_eq!(
259 identify_ahead_behind(&git_repo, &None, &None)
260 .unwrap_err()
261 .to_string(),
262 "a destination branch (to_branch) is not specified and the defaults (main or master) do not exist",
263 );
264 Ok(())
265 }
266
267 #[test]
268 fn when_from_branch_is_none_return_as_head() -> Result<()> {
269 let test_repo = GitTestRepo::default();
270 let git_repo = Repo::from_path(&test_repo.dir)?;
271
272 test_repo.populate()?;
273 // create feature branch with 1 commit ahead
274 test_repo.create_branch("feature")?;
275 test_repo.checkout("feature")?;
276 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
277 let head_oid = test_repo.stage_and_commit("add t3.md")?;
278
279 // make feature branch 1 commit behind
280 test_repo.checkout("main")?;
281 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
282 let main_oid = test_repo.stage_and_commit("add t4.md")?;
283 // checkout feature
284 test_repo.checkout("feature")?;
285
286 let (from_branch, to_branch, ahead, behind) =
287 identify_ahead_behind(&git_repo, &None, &None)?;
288
289 assert_eq!(from_branch, "head");
290 assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]);
291 assert_eq!(to_branch, "main");
292 assert_eq!(behind, vec![oid_to_sha1(&main_oid)]);
293 Ok(())
294 }
295
296 #[test]
297 fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> {
298 let test_repo = GitTestRepo::default();
299 let git_repo = Repo::from_path(&test_repo.dir)?;
300
301 test_repo.populate()?;
302 // create feature branch with 1 commit ahead
303 test_repo.create_branch("feature")?;
304 test_repo.checkout("feature")?;
305 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
306 let head_oid = test_repo.stage_and_commit("add t3.md")?;
307
308 // make feature branch 1 commit behind
309 test_repo.checkout("main")?;
310 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
311 let main_oid = test_repo.stage_and_commit("add t4.md")?;
312
313 let (from_branch, to_branch, ahead, behind) =
314 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
315
316 assert_eq!(from_branch, "feature");
317 assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]);
318 assert_eq!(to_branch, "main");
319 assert_eq!(behind, vec![oid_to_sha1(&main_oid)]);
320 Ok(())
321 }
322
323 #[test]
324 fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> {
325 let test_repo = GitTestRepo::default();
326 let git_repo = Repo::from_path(&test_repo.dir)?;
327
328 test_repo.populate()?;
329 // create dev branch with 1 commit ahead
330 test_repo.create_branch("dev")?;
331 test_repo.checkout("dev")?;
332 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
333 let dev_oid_first = test_repo.stage_and_commit("add t3.md")?;
334
335 // create feature branch with 1 commit ahead of dev
336 test_repo.create_branch("feature")?;
337 test_repo.checkout("feature")?;
338 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
339 let feature_oid = test_repo.stage_and_commit("add t4.md")?;
340
341 // make feature branch 1 behind
342 test_repo.checkout("dev")?;
343 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
344 let dev_oid = test_repo.stage_and_commit("add t3.md")?;
345
346 let (from_branch, to_branch, ahead, behind) = identify_ahead_behind(
347 &git_repo,
348 &Some("feature".to_string()),
349 &Some("dev".to_string()),
350 )?;
351
352 assert_eq!(from_branch, "feature");
353 assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]);
354 assert_eq!(to_branch, "dev");
355 assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]);
356
357 let (from_branch, to_branch, ahead, behind) =
358 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
359
360 assert_eq!(from_branch, "feature");
361 assert_eq!(
362 ahead,
363 vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)]
364 );
365 assert_eq!(to_branch, "main");
366 assert_eq!(behind, vec![]);
367
368 Ok(())
369 }
370 }
371}
diff --git a/src/sub_commands/prs/mod.rs b/src/sub_commands/prs/mod.rs
new file mode 100644
index 0000000..c316e73
--- /dev/null
+++ b/src/sub_commands/prs/mod.rs
@@ -0,0 +1,22 @@
1use anyhow::Result;
2use clap::Subcommand;
3
4use crate::Cli;
5pub mod create;
6
7#[derive(clap::Parser)]
8pub struct SubCommandArgs {
9 #[command(subcommand)]
10 pub prs_command: Commands,
11}
12
13#[derive(Debug, Subcommand)]
14pub enum Commands {
15 Create(create::SubCommandArgs),
16}
17
18pub fn launch(cli_args: &Cli, pr_args: &SubCommandArgs) -> Result<()> {
19 match &pr_args.prs_command {
20 Commands::Create(args) => create::launch(cli_args, pr_args, args),
21 }
22}