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>2025-07-25 16:12:27 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2025-07-25 16:12:27 +0100
commite89dbc142f5a0a517f197562f5f228681d9aed47 (patch)
tree521dbec8d259f7c982345b40bb128a21795a2012 /src
parent419b827f7c0d826f5eedf574bce0cf9b85cab4ca (diff)
parent0cad465dd3f78bd6c680067d12d396d4782829bf (diff)
Merge branch 'add-PR-feature-to-remote'
Diffstat (limited to 'src')
-rw-r--r--src/bin/git_remote_nostr/fetch.rs105
-rw-r--r--src/bin/git_remote_nostr/list.rs56
-rw-r--r--src/bin/git_remote_nostr/push.rs333
-rw-r--r--src/bin/git_remote_nostr/utils.rs59
-rw-r--r--src/bin/ngit/sub_commands/init.rs16
-rw-r--r--src/bin/ngit/sub_commands/list.rs211
-rw-r--r--src/lib/client.rs164
-rw-r--r--src/lib/git/mod.rs20
-rw-r--r--src/lib/git_events.rs321
-rw-r--r--src/lib/login/fresh.rs4
-rw-r--r--src/lib/repo_ref.rs23
11 files changed, 1033 insertions, 279 deletions
diff --git a/src/bin/git_remote_nostr/fetch.rs b/src/bin/git_remote_nostr/fetch.rs
index b191850..f3d4362 100644
--- a/src/bin/git_remote_nostr/fetch.rs
+++ b/src/bin/git_remote_nostr/fetch.rs
@@ -1,6 +1,6 @@
1use core::str; 1use core::str;
2use std::{ 2use std::{
3 collections::HashMap, 3 collections::{HashMap, HashSet},
4 io::Stdin, 4 io::Stdin,
5 sync::{Arc, Mutex}, 5 sync::{Arc, Mutex},
6 time::Instant, 6 time::Instant,
@@ -16,7 +16,7 @@ use ngit::{
16 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol}, 16 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol},
17 utils::check_ssh_keys, 17 utils::check_ssh_keys,
18 }, 18 },
19 git_events::tag_value, 19 git_events::{KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, tag_value},
20 login::get_curent_user, 20 login::get_curent_user,
21 repo_ref::{RepoRef, is_grasp_server}, 21 repo_ref::{RepoRef, is_grasp_server},
22}; 22};
@@ -37,38 +37,78 @@ pub async fn run_fetch(
37) -> Result<()> { 37) -> Result<()> {
38 let mut fetch_batch = get_oids_from_fetch_batch(stdin, oid, refstr)?; 38 let mut fetch_batch = get_oids_from_fetch_batch(stdin, oid, refstr)?;
39 39
40 let oids_from_git_servers = fetch_batch 40 let oids_from_state = fetch_batch
41 .iter() 41 .iter()
42 .filter(|(refstr, _)| !refstr.contains("refs/heads/pr/")) 42 .filter(|(refstr, _)| !refstr.contains("refs/heads/pr/"))
43 .map(|(_, oid)| oid.clone()) 43 .map(|(_, oid)| oid.clone())
44 .collect::<Vec<String>>(); 44 .collect::<Vec<String>>();
45 45
46 let pr_oid_clone_url_map = identify_clone_urls_for_oids_from_pr_pr_update_events(
47 fetch_batch.values().collect::<Vec<&String>>(),
48 git_repo,
49 repo_ref,
50 )
51 .await?;
52
53 let oids_to_fetch_from_git_servers = [
54 oids_from_state.clone(),
55 pr_oid_clone_url_map
56 .keys()
57 .cloned()
58 .collect::<Vec<String>>(),
59 ]
60 .concat();
61
62 let git_servers = {
63 let mut seen: HashSet<String> = HashSet::new();
64 let mut out: Vec<String> = vec![];
65 for server in &repo_ref.git_server {
66 if seen.insert(server.clone()) {
67 out.push(server.clone());
68 }
69 }
70 for url in pr_oid_clone_url_map.values().flatten() {
71 if seen.insert(url.clone()) {
72 out.push(url.clone());
73 }
74 }
75 out
76 };
77
46 let mut errors = vec![]; 78 let mut errors = vec![];
47 let term = console::Term::stderr(); 79 let term = console::Term::stderr();
48 80
49 for git_server_url in &repo_ref.git_server { 81 for git_server_url in &git_servers {
82 let oids_to_fetch_from_server = oids_to_fetch_from_git_servers
83 .clone()
84 .into_iter()
85 .filter(|oid| !git_repo.does_commit_exist(oid).unwrap_or(false))
86 .collect::<Vec<String>>();
87
88 if oids_to_fetch_from_server.is_empty() {
89 continue;
90 }
91
50 let term = console::Term::stderr(); 92 let term = console::Term::stderr();
51 if let Err(error) = fetch_from_git_server( 93 if let Err(error) = fetch_from_git_server(
52 git_repo, 94 git_repo,
53 &oids_from_git_servers, 95 &oids_from_state,
54 git_server_url, 96 git_server_url,
55 &repo_ref.to_nostr_git_url(&None), 97 &repo_ref.to_nostr_git_url(&None),
56 &term, 98 &term,
57 is_grasp_server(git_server_url, &repo_ref.grasp_servers()), 99 is_grasp_server(git_server_url, &repo_ref.grasp_servers()),
58 ) { 100 ) {
59 errors.push(error); 101 errors.push(error);
60 } else {
61 break;
62 } 102 }
63 } 103 }
64 104
65 if oids_from_git_servers 105 if oids_from_state
66 .iter() 106 .iter()
67 .any(|oid| !git_repo.does_commit_exist(oid).unwrap()) 107 .any(|oid| !git_repo.does_commit_exist(oid).unwrap())
68 && !errors.is_empty() 108 && !errors.is_empty()
69 { 109 {
70 bail!( 110 bail!(
71 "fetch: failed to fetch objects in nostr state event from:\r\n{}", 111 "fetch: failed to fetch objects from:\r\n{}",
72 errors 112 errors
73 .iter() 113 .iter()
74 .map(|e| format!(" - {e}")) 114 .map(|e| format!(" - {e}"))
@@ -79,12 +119,43 @@ pub async fn run_fetch(
79 119
80 fetch_batch.retain(|refstr, _| refstr.contains("refs/heads/pr/")); 120 fetch_batch.retain(|refstr, _| refstr.contains("refs/heads/pr/"));
81 121
82 fetch_open_or_draft_proposals(git_repo, &term, repo_ref, &fetch_batch).await?; 122 fetch_open_or_draft_proposals_from_patches(git_repo, &term, repo_ref, &fetch_batch).await?;
123 // TODO fetch_open_or_draft_proposals just needs to do it for patches
83 term.flush()?; 124 term.flush()?;
84 println!(); 125 println!();
85 Ok(()) 126 Ok(())
86} 127}
87 128
129async fn identify_clone_urls_for_oids_from_pr_pr_update_events(
130 oids: Vec<&String>,
131 git_repo: &Repo,
132 repo_ref: &RepoRef,
133) -> Result<HashMap<String, Vec<String>>> {
134 let mut map: HashMap<String, Vec<String>> = HashMap::new();
135
136 let open_and_draft_proposals = get_open_or_draft_proposals(git_repo, repo_ref).await?;
137
138 for (_, (_, events)) in open_and_draft_proposals {
139 for event in events {
140 if [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&event.kind) {
141 if let Ok(c) = tag_value(&event, "c") {
142 if oids.contains(&&c) {
143 for tag in event.tags.as_slice() {
144 if tag.kind().eq(&nostr::event::TagKind::Clone) {
145 for clone_url in tag.as_slice().iter().skip(1) {
146 map.entry(c.clone()).or_default().push(clone_url.clone());
147 }
148 }
149 }
150 }
151 }
152 }
153 }
154 }
155
156 Ok(map)
157}
158
88pub fn make_commits_for_proposal( 159pub fn make_commits_for_proposal(
89 git_repo: &Repo, 160 git_repo: &Repo,
90 repo_ref: &RepoRef, 161 repo_ref: &RepoRef,
@@ -128,7 +199,7 @@ pub fn make_commits_for_proposal(
128 Ok(tip_commit_id) 199 Ok(tip_commit_id)
129} 200}
130 201
131async fn fetch_open_or_draft_proposals( 202async fn fetch_open_or_draft_proposals_from_patches(
132 git_repo: &Repo, 203 git_repo: &Repo,
133 term: &console::Term, 204 term: &console::Term,
134 repo_ref: &RepoRef, 205 repo_ref: &RepoRef,
@@ -140,12 +211,19 @@ async fn fetch_open_or_draft_proposals(
140 let current_user = get_curent_user(git_repo)?; 211 let current_user = get_curent_user(git_repo)?;
141 212
142 for refstr in proposal_refs.keys() { 213 for refstr in proposal_refs.keys() {
143 if let Some((_, (_, patches))) = find_proposal_and_patches_by_branch_name( 214 if let Some((_, (_, events_to_apply))) = find_proposal_and_patches_by_branch_name(
144 refstr, 215 refstr,
145 &open_and_draft_proposals, 216 &open_and_draft_proposals,
146 current_user.as_ref(), 217 current_user.as_ref(),
147 ) { 218 ) {
148 if let Err(error) = make_commits_for_proposal(git_repo, repo_ref, patches) { 219 if events_to_apply
220 .iter()
221 .any(|e| e.kind.eq(&KIND_PULL_REQUEST) || e.kind.eq(&KIND_PULL_REQUEST_UPDATE))
222 {
223 // do nothing - we fetch these oids as part of run_fetch
224 } else if let Err(error) =
225 make_commits_for_proposal(git_repo, repo_ref, events_to_apply)
226 {
149 term.write_line( 227 term.write_line(
150 format!("WARNING: failed to create branch for {refstr}, error: {error}",) 228 format!("WARNING: failed to create branch for {refstr}, error: {error}",)
151 .as_str(), 229 .as_str(),
@@ -429,6 +507,7 @@ fn fetch_from_git_server_url(
429 remote_callbacks.credentials(auth.credentials(&git_config)); 507 remote_callbacks.credentials(auth.credentials(&git_config));
430 } 508 }
431 fetch_options.remote_callbacks(remote_callbacks); 509 fetch_options.remote_callbacks(remote_callbacks);
510
432 git_server_remote.download(oids, Some(&mut fetch_options))?; 511 git_server_remote.download(oids, Some(&mut fetch_options))?;
433 512
434 git_server_remote.disconnect()?; 513 git_server_remote.disconnect()?;
diff --git a/src/bin/git_remote_nostr/list.rs b/src/bin/git_remote_nostr/list.rs
index b9fb0c0..7bdf170 100644
--- a/src/bin/git_remote_nostr/list.rs
+++ b/src/bin/git_remote_nostr/list.rs
@@ -11,7 +11,7 @@ use ngit::{
11 self, 11 self,
12 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol}, 12 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol},
13 }, 13 },
14 git_events::event_to_cover_letter, 14 git_events::{KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_to_cover_letter, tag_value},
15 login::get_curent_user, 15 login::get_curent_user,
16 repo_ref::{self, is_grasp_server}, 16 repo_ref::{self, is_grasp_server},
17}; 17};
@@ -122,6 +122,16 @@ async fn get_open_and_draft_proposals_state(
122 122
123 // without trusting commit_id we must apply each patch which requires the oid of 123 // without trusting commit_id we must apply each patch which requires the oid of
124 // the parent so we much do a fetch 124 // the parent so we much do a fetch
125
126 // As we are fetching from git servers we mighgt as well get oids from pull
127 // request too
128 // TODO get Pull Request and Pull Request Update Events add these to
129 // refs/nostr/<event-id>
130 // TODO prepare PRs and PRS oids to try and fetch from repo servers that are or
131 // clone urls in PR/update event we are using anyway. TODO after we tried
132 // and failed to get them from these server we should fallback to fetch them
133 // from listed clone urls in PR/update but not during list, only during fetch
134
125 for (git_server_url, (oids_from_git_servers, is_grasp_server)) in remote_states { 135 for (git_server_url, (oids_from_git_servers, is_grasp_server)) in remote_states {
126 if fetch_from_git_server( 136 if fetch_from_git_server(
127 git_repo, 137 git_repo,
@@ -144,7 +154,7 @@ async fn get_open_and_draft_proposals_state(
144 let mut state = HashMap::new(); 154 let mut state = HashMap::new();
145 let open_and_draft_proposals = get_open_or_draft_proposals(git_repo, repo_ref).await?; 155 let open_and_draft_proposals = get_open_or_draft_proposals(git_repo, repo_ref).await?;
146 let current_user = get_curent_user(git_repo)?; 156 let current_user = get_curent_user(git_repo)?;
147 for (_, (proposal, patches)) in open_and_draft_proposals { 157 for (_, (proposal, events_to_apply)) in open_and_draft_proposals {
148 if let Ok(cl) = event_to_cover_letter(&proposal) { 158 if let Ok(cl) = event_to_cover_letter(&proposal) {
149 if let Ok(mut branch_name) = cl.get_branch_name_with_pr_prefix_and_shorthand_id() { 159 if let Ok(mut branch_name) = cl.get_branch_name_with_pr_prefix_and_shorthand_id() {
150 branch_name = if let Some(public_key) = current_user { 160 branch_name = if let Some(public_key) = current_user {
@@ -156,15 +166,43 @@ async fn get_open_and_draft_proposals_state(
156 } else { 166 } else {
157 branch_name 167 branch_name
158 }; 168 };
159 match make_commits_for_proposal(git_repo, repo_ref, &patches) { 169 // if events_to_apply contains a PR or PR Update event it should be the only
160 Ok(tip) => { 170 // event in the Vec
161 state.insert(format!("refs/heads/{branch_name}"), tip); 171 if let Some(pr_or_pr_update) = events_to_apply
172 .iter()
173 .find(|e| e.kind.eq(&KIND_PULL_REQUEST) || e.kind.eq(&KIND_PULL_REQUEST_UPDATE))
174 {
175 match tag_value(pr_or_pr_update, "c") {
176 Ok(tip) => {
177 state.insert(format!("refs/heads/{branch_name}"), tip);
178 }
179 Err(_) => {
180 let _ = term.write_line(
181 format!(
182 "WARNING: failed to fetch branch {branch_name} error: {} event poorly formatted",
183 if pr_or_pr_update.kind.eq(&KIND_PULL_REQUEST) {
184 "PR"
185 } else {
186 "PR update"
187 }
188 )
189 .as_str(),
190 );
191 }
162 } 192 }
163 Err(error) => { 193 } else {
164 let _ = term.write_line( 194 match make_commits_for_proposal(git_repo, repo_ref, &events_to_apply) {
165 format!("WARNING: failed to fetch branch {branch_name} error: {error}") 195 Ok(tip) => {
196 state.insert(format!("refs/heads/{branch_name}"), tip);
197 }
198 Err(error) => {
199 let _ = term.write_line(
200 format!(
201 "WARNING: failed to fetch branch {branch_name} error: {error}"
202 )
166 .as_str(), 203 .as_str(),
167 ); 204 );
205 }
168 } 206 }
169 } 207 }
170 } 208 }
diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs
index 9ff8af0..909a0ab 100644
--- a/src/bin/git_remote_nostr/push.rs
+++ b/src/bin/git_remote_nostr/push.rs
@@ -12,23 +12,24 @@ use client::{get_events_from_local_cache, get_state_from_cache, send_events, sig
12use console::Term; 12use console::Term;
13use git::{RepoActions, sha1_to_oid}; 13use git::{RepoActions, sha1_to_oid};
14use git_events::{ 14use git_events::{
15 generate_cover_letter_and_patch_events, generate_patch_event, get_commit_id_from_patch, 15 generate_cover_letter_and_patch_events, generate_patch_event,
16 generate_unsigned_pr_or_update_event, get_commit_id_from_patch,
16}; 17};
17use git2::{Oid, Repository}; 18use git2::{Oid, Repository};
18use ngit::{ 19use ngit::{
19 cli_interactor::count_lines_per_msg_vec, 20 cli_interactor::count_lines_per_msg_vec,
20 client::{self, get_event_from_cache_by_id}, 21 client::{self, get_event_from_cache_by_id, sign_draft_event},
21 git::{ 22 git::{
22 self, 23 self,
23 nostr_url::{CloneUrl, NostrUrlDecoded}, 24 nostr_url::{CloneUrl, NostrUrlDecoded},
24 oid_to_shorthand_string, 25 oid_to_shorthand_string,
25 }, 26 },
26 git_events::{self, event_to_cover_letter, get_event_root}, 27 git_events::{self, KIND_PULL_REQUEST, event_to_cover_letter, get_event_root},
27 login::{self, user::UserRef}, 28 login::{self, user::UserRef},
28 repo_ref::{self, get_repo_config_from_yaml, is_grasp_server}, 29 repo_ref::{self, get_repo_config_from_yaml, is_grasp_server, normalize_grasp_server_url},
29 repo_state, 30 repo_state,
30}; 31};
31use nostr::nips::nip10::Marker; 32use nostr::{event::UnsignedEvent, nips::nip10::Marker};
32use nostr_sdk::{ 33use nostr_sdk::{
33 Event, EventBuilder, EventId, Kind, NostrSigner, PublicKey, RelayUrl, Tag, TagStandard, 34 Event, EventBuilder, EventId, Kind, NostrSigner, PublicKey, RelayUrl, Tag, TagStandard,
34 hashes::sha1::Hash as Sha1Hash, 35 hashes::sha1::Hash as Sha1Hash,
@@ -65,7 +66,7 @@ pub async fn run_push(
65 .cloned() 66 .cloned()
66 .collect::<Vec<String>>(); 67 .collect::<Vec<String>>();
67 68
68 let mut git_server_refspecs = refspecs 69 let mut git_state_refspecs = refspecs
69 .iter() 70 .iter()
70 .filter(|r| !r.contains("refs/heads/pr/")) 71 .filter(|r| !r.contains("refs/heads/pr/"))
71 .cloned() 72 .cloned()
@@ -105,12 +106,12 @@ pub async fn run_push(
105 let (rejected_refspecs, remote_refspecs) = create_rejected_refspecs_and_remotes_refspecs( 106 let (rejected_refspecs, remote_refspecs) = create_rejected_refspecs_and_remotes_refspecs(
106 &term, 107 &term,
107 git_repo, 108 git_repo,
108 &git_server_refspecs, 109 &git_state_refspecs,
109 &existing_state, 110 &existing_state,
110 &list_outputs, 111 &list_outputs,
111 )?; 112 )?;
112 113
113 git_server_refspecs.retain(|refspec| { 114 git_state_refspecs.retain(|refspec| {
114 if let Some(rejected) = rejected_refspecs.get(&refspec.to_string()) { 115 if let Some(rejected) = rejected_refspecs.get(&refspec.to_string()) {
115 let (_, to) = refspec_to_from_to(refspec).unwrap(); 116 let (_, to) = refspec_to_from_to(refspec).unwrap();
116 println!("error {to} {} out of sync with nostr", rejected.join(" ")); 117 println!("error {to} {} out of sync with nostr", rejected.join(" "));
@@ -121,11 +122,11 @@ pub async fn run_push(
121 }); 122 });
122 123
123 // all refspecs aren't rejected 124 // all refspecs aren't rejected
124 if !(git_server_refspecs.is_empty() && proposal_refspecs.is_empty()) { 125 if !(git_state_refspecs.is_empty() && proposal_refspecs.is_empty()) {
125 let (rejected_proposal_refspecs, rejected) = create_and_publish_events( 126 let (rejected_proposal_refspecs, rejected) = create_and_publish_events_and_proposals(
126 git_repo, 127 git_repo,
127 repo_ref, 128 repo_ref,
128 &git_server_refspecs, 129 &git_state_refspecs,
129 &proposal_refspecs, 130 &proposal_refspecs,
130 client, 131 client,
131 existing_state, 132 existing_state,
@@ -134,7 +135,7 @@ pub async fn run_push(
134 .await?; 135 .await?;
135 136
136 if !rejected { 137 if !rejected {
137 for refspec in git_server_refspecs.iter().chain(proposal_refspecs.iter()) { 138 for refspec in git_state_refspecs.iter().chain(proposal_refspecs.iter()) {
138 if rejected_proposal_refspecs.contains(refspec) { 139 if rejected_proposal_refspecs.contains(refspec) {
139 continue; 140 continue;
140 } 141 }
@@ -153,7 +154,7 @@ pub async fn run_push(
153 for (git_server_url, remote_refspecs) in remote_refspecs { 154 for (git_server_url, remote_refspecs) in remote_refspecs {
154 let remote_refspecs = remote_refspecs 155 let remote_refspecs = remote_refspecs
155 .iter() 156 .iter()
156 .filter(|refspec| git_server_refspecs.contains(refspec)) 157 .filter(|refspec| git_state_refspecs.contains(refspec))
157 .cloned() 158 .cloned()
158 .collect::<Vec<String>>(); 159 .collect::<Vec<String>>();
159 if !refspecs.is_empty() { 160 if !refspecs.is_empty() {
@@ -174,7 +175,7 @@ pub async fn run_push(
174 Ok(()) 175 Ok(())
175} 176}
176 177
177async fn create_and_publish_events( 178async fn create_and_publish_events_and_proposals(
178 git_repo: &Repo, 179 git_repo: &Repo,
179 repo_ref: &RepoRef, 180 repo_ref: &RepoRef,
180 git_server_refspecs: &Vec<String>, 181 git_server_refspecs: &Vec<String>,
@@ -320,18 +321,23 @@ async fn process_proposal_refspecs(
320 { 321 {
321 if refspec.starts_with('+') { 322 if refspec.starts_with('+') {
322 // force push 323 // force push
323 let (_, main_tip) = git_repo.get_main_or_master_branch()?; 324 let (main_branch_name, main_tip) = git_repo.get_main_or_master_branch()?;
324 let (mut ahead, _) = 325 let (mut ahead, _) =
325 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?; 326 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?;
326 ahead.reverse(); 327 ahead.reverse();
327 for patch in generate_cover_letter_and_patch_events( 328 if ahead.is_empty() {
328 None, 329 bail!(
330 "cannot push '{from}' as proposal as branch isn't ahead of '{main_branch_name}'"
331 );
332 }
333 for patch in generate_patches_or_pr_event_or_pr_updates(
329 git_repo, 334 git_repo,
335 repo_ref,
330 &ahead, 336 &ahead,
337 user_ref,
338 Some(proposal),
331 signer, 339 signer,
332 repo_ref, 340 term,
333 &Some(proposal.id.to_string()),
334 &[],
335 ) 341 )
336 .await? 342 .await?
337 { 343 {
@@ -355,27 +361,50 @@ async fn process_proposal_refspecs(
355 }; 361 };
356 let mut parent_patch = tip_patch.clone(); 362 let mut parent_patch = tip_patch.clone();
357 ahead.reverse(); 363 ahead.reverse();
358 for (i, commit) in ahead.iter().enumerate() { 364 if ahead.is_empty() {
359 let new_patch = generate_patch_event( 365 bail!(
366 "cannot push '{from}' as proposal as branch isn't ahead of proposal on nostr"
367 );
368 }
369 if proposal.kind.eq(&KIND_PULL_REQUEST)
370 || are_commits_too_big_for_patches(git_repo, &ahead)
371 {
372 for event in generate_patches_or_pr_event_or_pr_updates(
360 git_repo, 373 git_repo,
361 &git_repo.get_root_commit()?,
362 commit,
363 Some(thread_id),
364 signer,
365 repo_ref, 374 repo_ref,
366 Some(parent_patch.id), 375 &ahead,
367 Some(( 376 user_ref,
368 (patches.len() + i + 1).try_into().unwrap(), 377 Some(proposal),
369 (patches.len() + ahead.len()).try_into().unwrap(), 378 signer,
370 )), 379 term,
371 None,
372 &None,
373 &[],
374 ) 380 )
375 .await 381 .await?
376 .context("failed to make patch event from commit")?; 382 {
377 events.push(new_patch.clone()); 383 events.push(event);
378 parent_patch = new_patch; 384 }
385 } else {
386 for (i, commit) in ahead.iter().enumerate() {
387 let new_patch = generate_patch_event(
388 git_repo,
389 &git_repo.get_root_commit()?,
390 commit,
391 Some(thread_id),
392 signer,
393 repo_ref,
394 Some(parent_patch.id),
395 Some((
396 (patches.len() + i + 1).try_into().unwrap(),
397 (patches.len() + ahead.len()).try_into().unwrap(),
398 )),
399 None,
400 &None,
401 &[],
402 )
403 .await
404 .context("failed to make patch event from commit")?;
405 events.push(new_patch.clone());
406 parent_patch = new_patch;
407 }
379 } 408 }
380 } else { 409 } else {
381 // we shouldn't get here 410 // we shouldn't get here
@@ -400,22 +429,21 @@ async fn process_proposal_refspecs(
400 } 429 }
401 } else { 430 } else {
402 // TODO new proposal / couldn't find exisiting proposal 431 // TODO new proposal / couldn't find exisiting proposal
403 let (_, main_tip) = git_repo.get_main_or_master_branch()?; 432 let (main_branch_name, main_tip) = git_repo.get_main_or_master_branch()?;
404 let (mut ahead, _) = 433 let (mut ahead, _) =
405 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?; 434 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?;
406 ahead.reverse(); 435 ahead.reverse();
407 for patch in generate_cover_letter_and_patch_events( 436 if ahead.is_empty() {
408 None, 437 bail!(
409 git_repo, 438 "cannot push '{from}' as proposal as branch isn't ahead of '{main_branch_name}'"
410 &ahead, 439 );
411 signer, 440 }
412 repo_ref, 441 for event in generate_patches_or_pr_event_or_pr_updates(
413 &None, 442 git_repo, repo_ref, &ahead, user_ref, None, signer, term,
414 &[],
415 ) 443 )
416 .await? 444 .await?
417 { 445 {
418 events.push(patch); 446 events.push(event);
419 } 447 }
420 } 448 }
421 } 449 }
@@ -423,6 +451,145 @@ async fn process_proposal_refspecs(
423 Ok((events, rejected_proposal_refspecs)) 451 Ok((events, rejected_proposal_refspecs))
424} 452}
425 453
454fn are_commits_too_big_for_patches(git_repo: &Repo, commits: &[Sha1Hash]) -> bool {
455 commits.iter().any(|commit| {
456 if let Ok(patch) = git_repo.make_patch_from_commit(commit, &None) {
457 patch.len()
458 > ((65 // max recomended patch event size specified in nip34 in kb
459 // allownace for nostr event wrapper (id, pubkey, tags, sig)
460 - 1) * 1024)
461 } else {
462 true
463 }
464 })
465}
466
467#[allow(clippy::too_many_lines)]
468async fn generate_patches_or_pr_event_or_pr_updates(
469 git_repo: &Repo,
470 repo_ref: &RepoRef,
471 ahead: &[Sha1Hash],
472 user_ref: &UserRef,
473 root_proposal: Option<&Event>,
474 signer: &Arc<dyn NostrSigner>,
475 term: &Term,
476) -> Result<Vec<Event>> {
477 let mut events: Vec<Event> = vec![];
478 let use_pr = root_proposal.is_some_and(|proposal| proposal.kind.eq(&KIND_PULL_REQUEST))
479 || are_commits_too_big_for_patches(git_repo, ahead);
480
481 if use_pr {
482 let repo_grasps = repo_ref.grasp_servers();
483 let repo_grasp_clone_urls = repo_ref
484 .git_server
485 .iter()
486 .filter(|s| is_grasp_server(s, &repo_grasps));
487
488 let mut unsigned_pr_event: Option<UnsignedEvent> = None;
489 let mut failed_clone_urls = vec![];
490 for clone_url in repo_grasp_clone_urls {
491 let mut draft_pr_event = if let Some(ref unsigned_pr_event) = unsigned_pr_event {
492 unsigned_pr_event.clone()
493 } else {
494 generate_unsigned_pr_or_update_event(
495 git_repo,
496 repo_ref,
497 &user_ref.public_key,
498 root_proposal,
499 ahead.first().context("no commits to push")?,
500 &[clone_url],
501 &[],
502 )?
503 };
504
505 let refspec = format!(
506 "{}:refs/nostr/{}",
507 ahead.first().unwrap(),
508 draft_pr_event.id()
509 );
510
511 if let Err(error) = push_to_remote_url(git_repo, clone_url, &[refspec], term) {
512 failed_clone_urls.push(clone_url);
513 term.write_line(
514 format!(
515 "push: error sending commit data to {}: {error}",
516 normalize_grasp_server_url(clone_url)?
517 )
518 .as_str(),
519 )?;
520 } else {
521 term.write_line(
522 format!(
523 "push: commit data sent to {}",
524 normalize_grasp_server_url(clone_url)?
525 )
526 .as_str(),
527 )?;
528 unsigned_pr_event = Some(draft_pr_event);
529 }
530 }
531 if unsigned_pr_event.is_none() {
532 bail!(
533 "a commit in your proposal is too big for a nostr patch. The repository doesnt list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request. Soon ngit will support pushing your changes to a different git / grasp git server."
534 );
535
536 // TODO get grasp_default_set servers that aren't in repo_grasps
537 // cycle through until one succeeds TODO create
538 // personal-fork announcement with grasp servers and
539 // push, after a few seconds push ref/nostr/eventid. if
540 // one success break out of for loop and continue
541 }
542 if let Some(unsigned_pr_event) = unsigned_pr_event {
543 let pr_event = sign_draft_event(
544 unsigned_pr_event,
545 signer,
546 if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) {
547 "Pull Request Replacing Original Patch"
548 } else if root_proposal.is_some() {
549 "Pull Request Update"
550 } else {
551 "Pull Request"
552 }
553 .to_string(),
554 )
555 .await?;
556 events.push(pr_event);
557 if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) {
558 events.push(
559 create_close_status_for_original_patch(
560 signer,
561 repo_ref,
562 root_proposal.unwrap(),
563 )
564 .await?,
565 );
566 }
567 } else {
568 bail!(
569 "a commit in your proposal is too big for a nostr patch. tried to use submit as a nostr Pull Request but could not find a grasp server that would accept your changes"
570 );
571 // TODO suggest `ngit send` where user could specify their own clone
572 // url to push to once that feature is added
573 }
574 } else {
575 for patch in generate_cover_letter_and_patch_events(
576 None,
577 git_repo,
578 ahead,
579 signer,
580 repo_ref,
581 &root_proposal.map(|proposal| proposal.id.to_string()),
582 &[],
583 )
584 .await?
585 {
586 events.push(patch);
587 }
588 }
589
590 Ok(events)
591}
592
426fn push_to_remote( 593fn push_to_remote(
427 git_repo: &Repo, 594 git_repo: &Repo,
428 git_server_url: &str, 595 git_server_url: &str,
@@ -1079,7 +1246,7 @@ type MergedProposalsInfo =
1079async fn get_merged_proposals_info( 1246async fn get_merged_proposals_info(
1080 git_repo: &Repo, 1247 git_repo: &Repo,
1081 ahead: &Vec<Sha1Hash>, 1248 ahead: &Vec<Sha1Hash>,
1082 available_patches: &[Event], 1249 available_patches_pr_pr_update: &[Event],
1083) -> Result<MergedProposalsInfo> { 1250) -> Result<MergedProposalsInfo> {
1084 let mut proposals: MergedProposalsInfo = HashMap::new(); 1251 let mut proposals: MergedProposalsInfo = HashMap::new();
1085 1252
@@ -1089,19 +1256,19 @@ async fn get_merged_proposals_info(
1089 // are in ahead 1256 // are in ahead
1090 if commit.parent_count() > 1 { 1257 if commit.parent_count() > 1 {
1091 for parent in commit.parents() { 1258 for parent in commit.parents() {
1092 for patch_event in available_patches 1259 for event in available_patches_pr_pr_update
1093 .iter() 1260 .iter()
1094 .filter(|e| { 1261 .filter(|e| {
1095 e.tags.iter().any(|t| { 1262 e.tags.iter().any(|t| {
1096 t.as_slice().len() > 1 1263 t.as_slice().len() > 1
1097 && t.as_slice()[0].eq("commit") 1264 && (t.as_slice()[0].eq("commit") || t.as_slice()[0].eq("c"))
1098 && t.as_slice()[1].eq(&parent.id().to_string()) 1265 && t.as_slice()[1].eq(&parent.id().to_string())
1099 }) 1266 })
1100 }) 1267 })
1101 .collect::<Vec<&Event>>() 1268 .collect::<Vec<&Event>>()
1102 { 1269 {
1103 if let Ok((proposal_id, revision_id)) = 1270 if let Ok((proposal_id, revision_id)) =
1104 get_proposal_and_revision_root_from_patch(git_repo, patch_event).await 1271 get_proposal_and_revision_root_from_patch(git_repo, event).await
1105 { 1272 {
1106 let (entry_revision_id, merged_patches) = 1273 let (entry_revision_id, merged_patches) =
1107 proposals.entry(proposal_id).or_default(); 1274 proposals.entry(proposal_id).or_default();
@@ -1114,12 +1281,12 @@ async fn get_merged_proposals_info(
1114 } else { 1281 } else {
1115 // three way merge or fast forward merge commits 1282 // three way merge or fast forward merge commits
1116 // note: ahead included commits of three-way merged branches 1283 // note: ahead included commits of three-way merged branches
1117 let mut matching_patches = available_patches 1284 let mut matching_patches = available_patches_pr_pr_update
1118 .iter() 1285 .iter()
1119 .filter(|e| { 1286 .filter(|e| {
1120 e.tags.iter().any(|t| { 1287 e.tags.iter().any(|t| {
1121 t.as_slice().len() > 1 1288 t.as_slice().len() > 1
1122 && t.as_slice()[0].eq("commit") 1289 && (t.as_slice()[0].eq("commit") || t.as_slice()[0].eq("c"))
1123 && t.as_slice()[1].eq(&commit_hash.to_string()) 1290 && t.as_slice()[1].eq(&commit_hash.to_string())
1124 }) 1291 })
1125 }) 1292 })
@@ -1144,7 +1311,7 @@ async fn get_merged_proposals_info(
1144 // applied commits - this is done after so that merged revisions take priority 1311 // applied commits - this is done after so that merged revisions take priority
1145 if matching_patches.is_empty() { 1312 if matching_patches.is_empty() {
1146 let author = git_repo.get_commit_author(commit_hash)?; 1313 let author = git_repo.get_commit_author(commit_hash)?;
1147 matching_patches = available_patches 1314 matching_patches = available_patches_pr_pr_update
1148 .iter() 1315 .iter()
1149 .filter(|e| { 1316 .filter(|e| {
1150 if let Ok(patch_author) = get_patch_author(e) { 1317 if let Ok(patch_author) = get_patch_author(e) {
@@ -1391,6 +1558,62 @@ async fn create_merge_status(
1391 .await 1558 .await
1392} 1559}
1393 1560
1561async fn create_close_status_for_original_patch(
1562 signer: &Arc<dyn NostrSigner>,
1563 repo_ref: &RepoRef,
1564 proposal: &Event,
1565) -> Result<Event> {
1566 let mut public_keys = repo_ref
1567 .maintainers
1568 .iter()
1569 .copied()
1570 .collect::<HashSet<PublicKey>>();
1571 public_keys.insert(proposal.pubkey);
1572
1573 sign_event(
1574 EventBuilder::new(nostr::event::Kind::GitStatusClosed, String::new()).tags(
1575 [
1576 vec![
1577 Tag::custom(
1578 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
1579 vec![
1580 "Git patch closed as forthcoming update is too large. Replacing with Pull Request"
1581 .to_string(),
1582 ],
1583 ),
1584 Tag::from_standardized(nostr::TagStandard::Event {
1585 event_id: proposal.id,
1586 relay_url: repo_ref.relays.first().cloned(),
1587 marker: Some(Marker::Root),
1588 public_key: None,
1589 uppercase: false,
1590 }),
1591 ],
1592 public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(),
1593 repo_ref
1594 .coordinates()
1595 .iter()
1596 .map(|c| {
1597 Tag::from_standardized(TagStandard::Coordinate {
1598 coordinate: c.coordinate.clone(),
1599 relay_url: c.relays.first().cloned(),
1600 uppercase: false,
1601 })
1602 })
1603 .collect::<Vec<Tag>>(),
1604 vec![
1605 Tag::from_standardized(nostr::TagStandard::Reference(
1606 repo_ref.root_commit.to_string(),
1607 )),
1608 ],
1609 ]
1610 .concat(),
1611 ),
1612 signer,
1613 "close status for original patch".to_string(),
1614 )
1615 .await
1616}
1394async fn get_proposal_and_revision_root_from_patch( 1617async fn get_proposal_and_revision_root_from_patch(
1395 git_repo: &Repo, 1618 git_repo: &Repo,
1396 patch: &Event, 1619 patch: &Event,
diff --git a/src/bin/git_remote_nostr/utils.rs b/src/bin/git_remote_nostr/utils.rs
index dc75872..2cb85bf 100644
--- a/src/bin/git_remote_nostr/utils.rs
+++ b/src/bin/git_remote_nostr/utils.rs
@@ -10,7 +10,7 @@ use anyhow::{Context, Result, bail};
10use git2::Repository; 10use git2::Repository;
11use ngit::{ 11use ngit::{
12 client::{ 12 client::{
13 get_all_proposal_patch_events_from_cache, get_events_from_local_cache, 13 get_all_proposal_patch_pr_pr_update_events_from_cache, get_events_from_local_cache,
14 get_proposals_and_revisions_from_cache, 14 get_proposals_and_revisions_from_cache,
15 }, 15 },
16 git::{ 16 git::{
@@ -18,7 +18,7 @@ use ngit::{
18 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol}, 18 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol},
19 }, 19 },
20 git_events::{ 20 git_events::{
21 event_is_revision_root, get_most_recent_patch_with_ancestors, 21 event_is_revision_root, get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status,
22 is_event_proposal_root_for_branch, status_kinds, 22 is_event_proposal_root_for_branch, status_kinds,
23 }, 23 },
24 repo_ref::RepoRef, 24 repo_ref::RepoRef,
@@ -103,7 +103,10 @@ pub async fn get_open_or_draft_proposals(
103 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()) 103 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates())
104 .await? 104 .await?
105 .iter() 105 .iter()
106 .filter(|e| !event_is_revision_root(e)) 106 .filter(|e|
107 // If we wanted to treat to list Pull Requests that revise a Patch we would do this:
108 // e.kind.eq(&KIND_PULL_REQUEST) ||
109 !event_is_revision_root(e))
107 .cloned() 110 .cloned()
108 .collect(); 111 .collect();
109 112
@@ -123,32 +126,23 @@ pub async fn get_open_or_draft_proposals(
123 }; 126 };
124 let mut open_or_draft_proposals = HashMap::new(); 127 let mut open_or_draft_proposals = HashMap::new();
125 128
126 for proposal in proposals { 129 for proposal in &proposals {
127 let status = if let Some(e) = statuses 130 let status = get_status(proposal, repo_ref, &statuses, &proposals);
128 .iter()
129 .filter(|e| {
130 status_kinds().contains(&e.kind)
131 && e.tags.iter().any(|t| {
132 t.as_slice().len() > 1 && t.as_slice()[1].eq(&proposal.id.to_string())
133 })
134 })
135 .collect::<Vec<&nostr::Event>>()
136 .first()
137 {
138 e.kind
139 } else {
140 Kind::GitStatusOpen
141 };
142 if [Kind::GitStatusOpen, Kind::GitStatusDraft].contains(&status) { 131 if [Kind::GitStatusOpen, Kind::GitStatusDraft].contains(&status) {
143 if let Ok(commits_events) = 132 if let Ok(commits_events) = get_all_proposal_patch_pr_pr_update_events_from_cache(
144 get_all_proposal_patch_events_from_cache(git_repo_path, repo_ref, &proposal.id) 133 git_repo_path,
145 .await 134 repo_ref,
135 &proposal.id,
136 )
137 .await
146 { 138 {
147 if let Ok(most_recent_proposal_patch_chain) = 139 if let Ok(most_recent_proposal_patch_chain) =
148 get_most_recent_patch_with_ancestors(commits_events.clone()) 140 get_pr_tip_event_or_most_recent_patch_with_ancestors(commits_events.clone())
149 { 141 {
150 open_or_draft_proposals 142 open_or_draft_proposals.insert(
151 .insert(proposal.id, (proposal, most_recent_proposal_patch_chain)); 143 proposal.id,
144 (proposal.clone(), most_recent_proposal_patch_chain),
145 );
152 } 146 }
153 } 147 }
154 } 148 }
@@ -165,18 +159,25 @@ pub async fn get_all_proposals(
165 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()) 159 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates())
166 .await? 160 .await?
167 .iter() 161 .iter()
168 .filter(|e| !event_is_revision_root(e)) 162 .filter(|e|
163 // If we wanted to treat to list Pull Requests that revise a Patch we would do this:
164 // e.kind.eq(&KIND_PULL_REQUEST) ||
165 !event_is_revision_root(e))
169 .cloned() 166 .cloned()
170 .collect(); 167 .collect();
171 168
172 let mut all_proposals = HashMap::new(); 169 let mut all_proposals = HashMap::new();
173 170
174 for proposal in proposals { 171 for proposal in proposals {
175 if let Ok(commits_events) = 172 if let Ok(commits_events) = get_all_proposal_patch_pr_pr_update_events_from_cache(
176 get_all_proposal_patch_events_from_cache(git_repo_path, repo_ref, &proposal.id).await 173 git_repo_path,
174 repo_ref,
175 &proposal.id,
176 )
177 .await
177 { 178 {
178 if let Ok(most_recent_proposal_patch_chain) = 179 if let Ok(most_recent_proposal_patch_chain) =
179 get_most_recent_patch_with_ancestors(commits_events.clone()) 180 get_pr_tip_event_or_most_recent_patch_with_ancestors(commits_events.clone())
180 { 181 {
181 all_proposals.insert(proposal.id, (proposal, most_recent_proposal_patch_chain)); 182 all_proposals.insert(proposal.id, (proposal, most_recent_proposal_patch_chain));
182 } 183 }
diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs
index 1242e45..eaaf83d 100644
--- a/src/bin/ngit/sub_commands/init.rs
+++ b/src/bin/ngit/sub_commands/init.rs
@@ -229,7 +229,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
229 .map(std::string::ToString::to_string) 229 .map(std::string::ToString::to_string)
230 .collect::<Vec<String>>() 230 .collect::<Vec<String>>()
231 } else { 231 } else {
232 client.get_fallback_relays().clone() 232 client.get_relay_default_set().clone()
233 } 233 }
234 } else { 234 } else {
235 args.relays.clone() 235 args.relays.clone()
@@ -252,14 +252,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
252 args.blossoms.clone() 252 args.blossoms.clone()
253 }; 253 };
254 254
255 let fallback_grasp_servers = 255 let fallback_grasp_servers = client.get_grasp_default_set();
256 if let Ok(Some(s)) = git_repo.get_git_config_item("nostr.grasp-default-set", None) {
257 s.split(';')
258 .filter_map(|url| normalize_grasp_server_url(url).ok()) // Attempt to parse and filter out errors
259 .collect()
260 } else {
261 vec!["relay.ngit.dev".to_string(), "gitnostr.com".to_string()]
262 };
263 256
264 let selected_grasp_servers = if has_server_and_relay_flags { 257 let selected_grasp_servers = if has_server_and_relay_flags {
265 // ignore so a script running `ngit init` can contiue without prompts 258 // ignore so a script running `ngit init` can contiue without prompts
@@ -269,14 +262,13 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
269 repo_ref.as_ref(), 262 repo_ref.as_ref(),
270 &args.relays, 263 &args.relays,
271 &args.clone_url, 264 &args.clone_url,
272 &args.blossoms,
273 &identifier, 265 &identifier,
274 ); 266 );
275 let mut selections: Vec<bool> = vec![true; options.len()]; // Initialize selections based on existing options 267 let mut selections: Vec<bool> = vec![true; options.len()]; // Initialize selections based on existing options
276 let empty = options.is_empty(); 268 let empty = options.is_empty();
277 for fallback in fallback_grasp_servers { 269 for fallback in fallback_grasp_servers {
278 // Check if any option contains the fallback as a substring 270 // Check if any option contains the fallback as a substring
279 if !options.iter().any(|option| option.contains(&fallback)) { 271 if !options.iter().any(|option| option.contains(fallback)) {
280 options.push(fallback.clone()); // Add fallback if not found 272 options.push(fallback.clone()); // Add fallback if not found
281 selections.push(empty); // mark as selected if no existing ngit relay otherwise not 273 selections.push(empty); // mark as selected if no existing ngit relay otherwise not
282 } 274 }
@@ -464,7 +456,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
464 let mut selections: Vec<bool> = vec![true; options.len()]; 456 let mut selections: Vec<bool> = vec![true; options.len()];
465 457
466 // add fallback relays as options 458 // add fallback relays as options
467 for relay in client.get_fallback_relays().clone() { 459 for relay in client.get_relay_default_set().clone() {
468 if !options.iter().any(|r| r.contains(&relay)) 460 if !options.iter().any(|r| r.contains(&relay))
469 && !formatted_selected_grasp_servers 461 && !formatted_selected_grasp_servers
470 .iter() 462 .iter()
diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs
index 0330be1..0083c91 100644
--- a/src/bin/ngit/sub_commands/list.rs
+++ b/src/bin/ngit/sub_commands/list.rs
@@ -3,10 +3,12 @@ use std::{io::Write, ops::Add};
3use anyhow::{Context, Result, bail}; 3use anyhow::{Context, Result, bail};
4use ngit::{ 4use ngit::{
5 client::{ 5 client::{
6 Params, get_all_proposal_patch_events_from_cache, get_proposals_and_revisions_from_cache, 6 Params, get_all_proposal_patch_pr_pr_update_events_from_cache,
7 get_proposals_and_revisions_from_cache,
7 }, 8 },
8 git_events::{ 9 git_events::{
9 get_commit_id_from_patch, get_most_recent_patch_with_ancestors, status_kinds, tag_value, 10 KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, get_commit_id_from_patch,
11 get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, status_kinds, tag_value,
10 }, 12 },
11}; 13};
12use nostr_sdk::Kind; 14use nostr_sdk::Kind;
@@ -70,26 +72,15 @@ pub async fn launch() -> Result<()> {
70 72
71 let proposals: Vec<nostr::Event> = proposals_and_revisions 73 let proposals: Vec<nostr::Event> = proposals_and_revisions
72 .iter() 74 .iter()
73 .filter(|e| !event_is_revision_root(e)) 75 .filter(|e|
76 // If we wanted to treat to list Pull Requests that revise a Patch we would do this:
77 // e.kind.eq(&KIND_PULL_REQUEST) ||
78 !event_is_revision_root(e))
74 .cloned() 79 .cloned()
75 .collect(); 80 .collect();
76 81
77 for proposal in &proposals { 82 for proposal in &proposals {
78 let status = if let Some(e) = statuses 83 let status = get_status(proposal, &repo_ref, &statuses, &proposals);
79 .iter()
80 .filter(|e| {
81 status_kinds().contains(&e.kind)
82 && e.tags.iter().any(|t| {
83 t.as_slice().len() > 1 && t.as_slice()[1].eq(&proposal.id.to_string())
84 })
85 })
86 .collect::<Vec<&nostr::Event>>()
87 .first()
88 {
89 e.kind
90 } else {
91 Kind::GitStatusOpen
92 };
93 if status.eq(&Kind::GitStatusOpen) { 84 if status.eq(&Kind::GitStatusOpen) {
94 open_proposals.push(proposal); 85 open_proposals.push(proposal);
95 } else if status.eq(&Kind::GitStatusClosed) { 86 } else if status.eq(&Kind::GitStatusClosed) {
@@ -184,21 +175,22 @@ pub async fn launch() -> Result<()> {
184 let cover_letter = event_to_cover_letter(proposals_for_status[selected_index]) 175 let cover_letter = event_to_cover_letter(proposals_for_status[selected_index])
185 .context("failed to extract proposal details from proposal root event")?; 176 .context("failed to extract proposal details from proposal root event")?;
186 177
187 let commits_events: Vec<nostr::Event> = get_all_proposal_patch_events_from_cache( 178 let commits_events: Vec<nostr::Event> =
188 git_repo_path, 179 get_all_proposal_patch_pr_pr_update_events_from_cache(
189 &repo_ref, 180 git_repo_path,
190 &proposals_for_status[selected_index].id, 181 &repo_ref,
191 ) 182 &proposals_for_status[selected_index].id,
192 .await?; 183 )
184 .await?;
193 185
194 let Ok(most_recent_proposal_patch_chain) = 186 let Ok(most_recent_proposal_patch_chain_or_pr_or_pr_update) =
195 get_most_recent_patch_with_ancestors(commits_events.clone()) 187 get_pr_tip_event_or_most_recent_patch_with_ancestors(commits_events.clone())
196 else { 188 else {
197 if Interactor::default().confirm( 189 if Interactor::default().confirm(
198 PromptConfirmParms::default() 190 PromptConfirmParms::default()
199 .with_default(true) 191 .with_default(true)
200 .with_prompt( 192 .with_prompt(
201 "failed to find any patches on this proposal. choose another proposal?", 193 "failed to find any PR or patch events on this proposal. choose another proposal?",
202 ), 194 ),
203 )? { 195 )? {
204 continue; 196 continue;
@@ -208,15 +200,60 @@ pub async fn launch() -> Result<()> {
208 // for commit in &most_recent_proposal_patch_chain { 200 // for commit in &most_recent_proposal_patch_chain {
209 // println!("recent_event: {:?}", commit.as_json()); 201 // println!("recent_event: {:?}", commit.as_json());
210 // } 202 // }
203 if most_recent_proposal_patch_chain_or_pr_or_pr_update
204 .iter()
205 .any(|e| [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&e.kind))
206 {
207 match Interactor::default().choice(
208 PromptChoiceParms::default()
209 .with_prompt(
210 "this is new PR event kind which isn't supported in `ngit list` yet",
211 )
212 .with_default(0)
213 .with_choices(
214 if [Kind::GitStatusOpen, Kind::GitStatusDraft].contains(&selected_status)
215 && git_repo
216 .get_first_nostr_remote_when_in_ngit_binary()
217 .await
218 .is_ok_and(|r| r.is_some())
219 {
220 vec![
221 format!(
222 "I'll manually checkout the proposal at remote branch '{}'",
223 cover_letter
224 .get_branch_name_with_pr_prefix_and_shorthand_id()
225 .unwrap()
226 ),
227 // TODO fetch oids and follow similar logic for dealing with
228 // conflcts as with patches below
229 "back to proposals".to_string(),
230 ]
231 } else {
232 vec!["back to proposals".to_string()]
233 },
234 ),
235 )? {
236 0 => continue,
237 _ => {
238 bail!("unexpected choice")
239 }
240 };
241 }
211 242
212 let binding_patch_text_ref = format!("{} commits", most_recent_proposal_patch_chain.len()); 243 let binding_patch_text_ref = format!(
213 let patch_text_ref = if most_recent_proposal_patch_chain.len().gt(&1) { 244 "{} commits",
245 most_recent_proposal_patch_chain_or_pr_or_pr_update.len()
246 );
247 let patch_text_ref = if most_recent_proposal_patch_chain_or_pr_or_pr_update
248 .len()
249 .gt(&1)
250 {
214 binding_patch_text_ref.as_str() 251 binding_patch_text_ref.as_str()
215 } else { 252 } else {
216 "1 commit" 253 "1 commit"
217 }; 254 };
218 255
219 let no_support_for_patches_as_branch = most_recent_proposal_patch_chain 256 let no_support_for_patches_as_branch = most_recent_proposal_patch_chain_or_pr_or_pr_update
220 .iter() 257 .iter()
221 .any(|event| !patch_supports_commit_ids(event)); 258 .any(|event| !patch_supports_commit_ids(event));
222 259
@@ -226,16 +263,18 @@ pub async fn launch() -> Result<()> {
226 PromptChoiceParms::default() 263 PromptChoiceParms::default()
227 .with_default(0) 264 .with_default(0)
228 .with_choices(vec![ 265 .with_choices(vec![
229 "learn why 'patch only' proposals can't be checked out".to_string(), 266 "learn why this proposals can't be checked out".to_string(),
230 format!("apply to current branch with `git am`"), 267 format!("apply to current branch with `git am`"),
231 format!("download to ./patches"), 268 format!("download to ./patches"),
232 "back".to_string(), 269 "back".to_string(),
233 ]), 270 ]),
234 )? { 271 )? {
235 0 => { 272 0 => {
236 println!("Some proposals are posted as 'patch only'\n");
237 println!( 273 println!(
238 "they are not anchored against a particular state of the code base like a standard proposal or a GitHub Pull Request can be\n" 274 "Some proposals are posted as patch without listing a parent commit\n"
275 );
276 println!(
277 "they are not anchored against a particular state of the code base like a standard patch or a pull request can be\n"
239 ); 278 );
240 println!( 279 println!(
241 "they are designed to reviewed by studying the diff (in a tool like gitworkshop.dev) and if acceptable by a maintainer, applied to the latest version of master with any conflicts resolved as the do so\n" 280 "they are designed to reviewed by studying the diff (in a tool like gitworkshop.dev) and if acceptable by a maintainer, applied to the latest version of master with any conflicts resolved as the do so\n"
@@ -244,7 +283,7 @@ pub async fn launch() -> Result<()> {
244 "this has proven to be a smoother workflow for large scale projects with a high frequency of changes, even when patches are exchanged via email\n" 283 "this has proven to be a smoother workflow for large scale projects with a high frequency of changes, even when patches are exchanged via email\n"
245 ); 284 );
246 println!( 285 println!(
247 "by default ngit posts proposals that support both the branch and patch model so either workflow can be used" 286 "by default ngit posts proposals with a parent commit so either workflow can be used"
248 ); 287 );
249 Interactor::default().choice( 288 Interactor::default().choice(
250 PromptChoiceParms::default() 289 PromptChoiceParms::default()
@@ -253,8 +292,13 @@ pub async fn launch() -> Result<()> {
253 )?; 292 )?;
254 continue; 293 continue;
255 } 294 }
256 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), 295 1 => {
257 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), 296 launch_git_am_with_patches(most_recent_proposal_patch_chain_or_pr_or_pr_update)
297 }
298 2 => save_patches_to_dir(
299 most_recent_proposal_patch_chain_or_pr_or_pr_update,
300 &git_repo,
301 ),
258 3 => continue, 302 3 => continue,
259 _ => { 303 _ => {
260 bail!("unexpected choice") 304 bail!("unexpected choice")
@@ -277,9 +321,11 @@ pub async fn launch() -> Result<()> {
277 .eq(&cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?); 321 .eq(&cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?);
278 322
279 let proposal_base_commit = str_to_sha1(&tag_value( 323 let proposal_base_commit = str_to_sha1(&tag_value(
280 most_recent_proposal_patch_chain.last().context( 324 most_recent_proposal_patch_chain_or_pr_or_pr_update
281 "there should be at least one patch as we have already checked for this", 325 .last()
282 )?, 326 .context(
327 "there should be at least one patch as we have already checked for this",
328 )?,
283 "parent-commit", 329 "parent-commit",
284 )?) 330 )?)
285 .context("failed to get valid parent commit id from patch")?; 331 .context("failed to get valid parent commit id from patch")?;
@@ -300,8 +346,8 @@ pub async fn launch() -> Result<()> {
300 ], 346 ],
301 ))? { 347 ))? {
302 0 | 3 => continue, 348 0 | 3 => continue,
303 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), 349 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain_or_pr_or_pr_update),
304 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), 350 2 => save_patches_to_dir(most_recent_proposal_patch_chain_or_pr_or_pr_update, &git_repo),
305 _ => { 351 _ => {
306 bail!("unexpected choice") 352 bail!("unexpected choice")
307 } 353 }
@@ -309,9 +355,13 @@ pub async fn launch() -> Result<()> {
309 } 355 }
310 356
311 let proposal_tip = str_to_sha1( 357 let proposal_tip = str_to_sha1(
312 &get_commit_id_from_patch(most_recent_proposal_patch_chain.first().context( 358 &get_commit_id_from_patch(
313 "there should be at least one patch as we have already checked for this", 359 most_recent_proposal_patch_chain_or_pr_or_pr_update
314 )?) 360 .first()
361 .context(
362 "there should be at least one patch as we have already checked for this",
363 )?,
364 )
315 .context("failed to get valid commit_id from patch")?, 365 .context("failed to get valid commit_id from patch")?,
316 ) 366 )
317 .context("failed to get valid commit_id from patch")?; 367 .context("failed to get valid commit_id from patch")?;
@@ -325,7 +375,7 @@ pub async fn launch() -> Result<()> {
325 .choice(PromptChoiceParms::default().with_default(0).with_choices(vec![ 375 .choice(PromptChoiceParms::default().with_default(0).with_choices(vec![
326 format!( 376 format!(
327 "create and checkout proposal branch ({} ahead {} behind '{main_branch_name}')", 377 "create and checkout proposal branch ({} ahead {} behind '{main_branch_name}')",
328 most_recent_proposal_patch_chain.len(), 378 most_recent_proposal_patch_chain_or_pr_or_pr_update.len(),
329 proposal_behind_main.len(), 379 proposal_behind_main.len(),
330 ), 380 ),
331 format!("apply to current branch with `git am`"), 381 format!("apply to current branch with `git am`"),
@@ -337,7 +387,7 @@ pub async fn launch() -> Result<()> {
337 let _ = git_repo 387 let _ = git_repo
338 .apply_patch_chain( 388 .apply_patch_chain(
339 &cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 389 &cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
340 most_recent_proposal_patch_chain, 390 most_recent_proposal_patch_chain_or_pr_or_pr_update,
341 ) 391 )
342 .context("failed to apply patch chain")?; 392 .context("failed to apply patch chain")?;
343 393
@@ -347,8 +397,8 @@ pub async fn launch() -> Result<()> {
347 ); 397 );
348 Ok(()) 398 Ok(())
349 } 399 }
350 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), 400 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain_or_pr_or_pr_update),
351 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), 401 2 => save_patches_to_dir(most_recent_proposal_patch_chain_or_pr_or_pr_update, &git_repo),
352 3 => continue, 402 3 => continue,
353 _ => { 403 _ => {
354 bail!("unexpected choice") 404 bail!("unexpected choice")
@@ -382,7 +432,7 @@ pub async fn launch() -> Result<()> {
382 .with_choices(vec![ 432 .with_choices(vec![
383 format!( 433 format!(
384 "checkout proposal branch ({} ahead {} behind '{main_branch_name}')", 434 "checkout proposal branch ({} ahead {} behind '{main_branch_name}')",
385 most_recent_proposal_patch_chain.len(), 435 most_recent_proposal_patch_chain_or_pr_or_pr_update.len(),
386 proposal_behind_main.len(), 436 proposal_behind_main.len(),
387 ), 437 ),
388 format!("apply to current branch with `git am`"), 438 format!("apply to current branch with `git am`"),
@@ -401,8 +451,13 @@ pub async fn launch() -> Result<()> {
401 ); 451 );
402 Ok(()) 452 Ok(())
403 } 453 }
404 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), 454 1 => {
405 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), 455 launch_git_am_with_patches(most_recent_proposal_patch_chain_or_pr_or_pr_update)
456 }
457 2 => save_patches_to_dir(
458 most_recent_proposal_patch_chain_or_pr_or_pr_update,
459 &git_repo,
460 ),
406 3 => continue, 461 3 => continue,
407 _ => { 462 _ => {
408 bail!("unexpected choice") 463 bail!("unexpected choice")
@@ -414,11 +469,14 @@ pub async fn launch() -> Result<()> {
414 git_repo.get_commits_ahead_behind(&master_tip, &local_branch_tip)?; 469 git_repo.get_commits_ahead_behind(&master_tip, &local_branch_tip)?;
415 470
416 // new appendments to proposal 471 // new appendments to proposal
417 if let Some(index) = most_recent_proposal_patch_chain.iter().position(|patch| { 472 if let Some(index) = most_recent_proposal_patch_chain_or_pr_or_pr_update
418 get_commit_id_from_patch(patch) 473 .iter()
419 .unwrap_or_default() 474 .position(|patch| {
420 .eq(&local_branch_tip.to_string()) 475 get_commit_id_from_patch(patch)
421 }) { 476 .unwrap_or_default()
477 .eq(&local_branch_tip.to_string())
478 })
479 {
422 return match Interactor::default().choice( 480 return match Interactor::default().choice(
423 PromptChoiceParms::default() 481 PromptChoiceParms::default()
424 .with_default(0) 482 .with_default(0)
@@ -437,7 +495,7 @@ pub async fn launch() -> Result<()> {
437 let _ = git_repo 495 let _ = git_repo
438 .apply_patch_chain( 496 .apply_patch_chain(
439 &cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 497 &cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
440 most_recent_proposal_patch_chain, 498 most_recent_proposal_patch_chain_or_pr_or_pr_update,
441 ) 499 )
442 .context("failed to apply patch chain")?; 500 .context("failed to apply patch chain")?;
443 println!( 501 println!(
@@ -448,8 +506,13 @@ pub async fn launch() -> Result<()> {
448 ); 506 );
449 Ok(()) 507 Ok(())
450 } 508 }
451 1 => launch_git_am_with_patches(most_recent_proposal_patch_chain), 509 1 => {
452 2 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), 510 launch_git_am_with_patches(most_recent_proposal_patch_chain_or_pr_or_pr_update)
511 }
512 2 => save_patches_to_dir(
513 most_recent_proposal_patch_chain_or_pr_or_pr_update,
514 &git_repo,
515 ),
453 3 => continue, 516 3 => continue,
454 _ => { 517 _ => {
455 bail!("unexpected choice") 518 bail!("unexpected choice")
@@ -467,7 +530,7 @@ pub async fn launch() -> Result<()> {
467 }) { 530 }) {
468 println!( 531 println!(
469 "updated proposal available ({} ahead {} behind '{main_branch_name}'). existing version is {} ahead {} behind '{main_branch_name}'", 532 "updated proposal available ({} ahead {} behind '{main_branch_name}'). existing version is {} ahead {} behind '{main_branch_name}'",
470 most_recent_proposal_patch_chain.len(), 533 most_recent_proposal_patch_chain_or_pr_or_pr_update.len(),
471 proposal_behind_main.len(), 534 proposal_behind_main.len(),
472 local_ahead_of_main.len(), 535 local_ahead_of_main.len(),
473 local_beind_main.len(), 536 local_beind_main.len(),
@@ -492,11 +555,11 @@ pub async fn launch() -> Result<()> {
492 git_repo.checkout( 555 git_repo.checkout(
493 &cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 556 &cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
494 )?; 557 )?;
495 let chain_length = most_recent_proposal_patch_chain.len(); 558 let chain_length = most_recent_proposal_patch_chain_or_pr_or_pr_update.len();
496 let _ = git_repo 559 let _ = git_repo
497 .apply_patch_chain( 560 .apply_patch_chain(
498 &cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 561 &cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
499 most_recent_proposal_patch_chain, 562 most_recent_proposal_patch_chain_or_pr_or_pr_update,
500 ) 563 )
501 .context("failed to apply patch chain")?; 564 .context("failed to apply patch chain")?;
502 println!( 565 println!(
@@ -520,8 +583,13 @@ pub async fn launch() -> Result<()> {
520 ); 583 );
521 Ok(()) 584 Ok(())
522 } 585 }
523 2 => launch_git_am_with_patches(most_recent_proposal_patch_chain), 586 2 => {
524 3 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), 587 launch_git_am_with_patches(most_recent_proposal_patch_chain_or_pr_or_pr_update)
588 }
589 3 => save_patches_to_dir(
590 most_recent_proposal_patch_chain_or_pr_or_pr_update,
591 &git_repo,
592 ),
525 4 => continue, 593 4 => continue,
526 _ => { 594 _ => {
527 bail!("unexpected choice") 595 bail!("unexpected choice")
@@ -581,7 +649,7 @@ pub async fn launch() -> Result<()> {
581 if git_repo.does_commit_exist(&proposal_tip.to_string())? { 649 if git_repo.does_commit_exist(&proposal_tip.to_string())? {
582 println!( 650 println!(
583 "you have previously applied the latest version of the proposal ({} ahead {} behind '{main_branch_name}') but your local proposal branch has amended or rebased it ({} ahead {} behind '{main_branch_name}')", 651 "you have previously applied the latest version of the proposal ({} ahead {} behind '{main_branch_name}') but your local proposal branch has amended or rebased it ({} ahead {} behind '{main_branch_name}')",
584 most_recent_proposal_patch_chain.len(), 652 most_recent_proposal_patch_chain_or_pr_or_pr_update.len(),
585 proposal_behind_main.len(), 653 proposal_behind_main.len(),
586 local_ahead_of_main.len(), 654 local_ahead_of_main.len(),
587 local_beind_main.len(), 655 local_beind_main.len(),
@@ -594,7 +662,7 @@ pub async fn launch() -> Result<()> {
594 "your local proposal branch ({} ahead {} behind '{main_branch_name}') has conflicting changes with the latest published proposal ({} ahead {} behind '{main_branch_name}')", 662 "your local proposal branch ({} ahead {} behind '{main_branch_name}') has conflicting changes with the latest published proposal ({} ahead {} behind '{main_branch_name}')",
595 local_ahead_of_main.len(), 663 local_ahead_of_main.len(),
596 local_beind_main.len(), 664 local_beind_main.len(),
597 most_recent_proposal_patch_chain.len(), 665 most_recent_proposal_patch_chain_or_pr_or_pr_update.len(),
598 proposal_behind_main.len(), 666 proposal_behind_main.len(),
599 ); 667 );
600 668
@@ -639,11 +707,11 @@ pub async fn launch() -> Result<()> {
639 &cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 707 &cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
640 &proposal_base_commit.to_string(), 708 &proposal_base_commit.to_string(),
641 )?; 709 )?;
642 let chain_length = most_recent_proposal_patch_chain.len(); 710 let chain_length = most_recent_proposal_patch_chain_or_pr_or_pr_update.len();
643 let _ = git_repo 711 let _ = git_repo
644 .apply_patch_chain( 712 .apply_patch_chain(
645 &cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?, 713 &cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?,
646 most_recent_proposal_patch_chain, 714 most_recent_proposal_patch_chain_or_pr_or_pr_update,
647 ) 715 )
648 .context("failed to apply patch chain")?; 716 .context("failed to apply patch chain")?;
649 717
@@ -658,8 +726,11 @@ pub async fn launch() -> Result<()> {
658 ); 726 );
659 Ok(()) 727 Ok(())
660 } 728 }
661 2 => launch_git_am_with_patches(most_recent_proposal_patch_chain), 729 2 => launch_git_am_with_patches(most_recent_proposal_patch_chain_or_pr_or_pr_update),
662 3 => save_patches_to_dir(most_recent_proposal_patch_chain, &git_repo), 730 3 => save_patches_to_dir(
731 most_recent_proposal_patch_chain_or_pr_or_pr_update,
732 &git_repo,
733 ),
663 4 => continue, 734 4 => continue,
664 _ => { 735 _ => {
665 bail!("unexpected choice") 736 bail!("unexpected choice")
diff --git a/src/lib/client.rs b/src/lib/client.rs
index e808bea..6f28cff 100644
--- a/src/lib/client.rs
+++ b/src/lib/client.rs
@@ -31,6 +31,7 @@ use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressState, P
31use mockall::*; 31use mockall::*;
32use nostr::{ 32use nostr::{
33 Event, 33 Event,
34 event::{TagKind, TagStandard, UnsignedEvent},
34 filter::Alphabet, 35 filter::Alphabet,
35 nips::{nip01::Coordinate, nip19::Nip19Coordinate}, 36 nips::{nip01::Coordinate, nip19::Nip19Coordinate},
36 signer::SignerBackend, 37 signer::SignerBackend,
@@ -47,20 +48,23 @@ use crate::{
47 get_dirs, 48 get_dirs,
48 git::{Repo, RepoActions, get_git_config_item}, 49 git::{Repo, RepoActions, get_git_config_item},
49 git_events::{ 50 git_events::{
50 event_is_cover_letter, event_is_patch_set_root, event_is_revision_root, status_kinds, 51 KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_is_cover_letter,
52 event_is_patch_set_root, event_is_revision_root, event_is_valid_pr_or_pr_update,
53 status_kinds,
51 }, 54 },
52 login::{get_likely_logged_in_user, user::get_user_ref_from_cache}, 55 login::{get_likely_logged_in_user, user::get_user_ref_from_cache},
53 repo_ref::RepoRef, 56 repo_ref::{RepoRef, normalize_grasp_server_url},
54 repo_state::RepoState, 57 repo_state::RepoState,
55}; 58};
56 59
57#[allow(clippy::struct_field_names)] 60#[allow(clippy::struct_field_names)]
58pub struct Client { 61pub struct Client {
59 client: nostr_sdk::Client, 62 client: nostr_sdk::Client,
60 fallback_relays: Vec<String>, 63 relay_default_set: Vec<String>,
61 more_fallback_relays: Vec<String>, 64 more_fallback_relays: Vec<String>,
62 blaster_relays: Vec<String>, 65 blaster_relays: Vec<String>,
63 fallback_signer_relays: Vec<String>, 66 fallback_signer_relays: Vec<String>,
67 grasp_default_set: Vec<String>,
64 relays_not_to_retry: Arc<RwLock<HashMap<RelayUrl, String>>>, 68 relays_not_to_retry: Arc<RwLock<HashMap<RelayUrl, String>>>,
65} 69}
66 70
@@ -94,10 +98,11 @@ pub trait Connect {
94 async fn set_signer(&mut self, signer: Arc<dyn NostrSigner>); 98 async fn set_signer(&mut self, signer: Arc<dyn NostrSigner>);
95 async fn connect(&self, relay_url: &RelayUrl) -> Result<()>; 99 async fn connect(&self, relay_url: &RelayUrl) -> Result<()>;
96 async fn disconnect(&self) -> Result<()>; 100 async fn disconnect(&self) -> Result<()>;
97 fn get_fallback_relays(&self) -> &Vec<String>; 101 fn get_relay_default_set(&self) -> &Vec<String>;
98 fn get_more_fallback_relays(&self) -> &Vec<String>; 102 fn get_more_fallback_relays(&self) -> &Vec<String>;
99 fn get_blaster_relays(&self) -> &Vec<String>; 103 fn get_blaster_relays(&self) -> &Vec<String>;
100 fn get_fallback_signer_relays(&self) -> &Vec<String>; 104 fn get_fallback_signer_relays(&self) -> &Vec<String>;
105 fn get_grasp_default_set(&self) -> &Vec<String>;
101 async fn send_event_to<'a>( 106 async fn send_event_to<'a>(
102 &self, 107 &self,
103 git_repo_path: Option<&'a Path>, 108 git_repo_path: Option<&'a Path>,
@@ -147,10 +152,11 @@ impl Connect for Client {
147 .opts(Options::new().relay_limits(RelayLimits::disable())) 152 .opts(Options::new().relay_limits(RelayLimits::disable()))
148 .build() 153 .build()
149 }, 154 },
150 fallback_relays: opts.fallback_relays, 155 relay_default_set: opts.relay_default_set,
151 more_fallback_relays: opts.more_fallback_relays, 156 more_fallback_relays: opts.more_fallback_relays,
152 blaster_relays: opts.blaster_relays, 157 blaster_relays: opts.blaster_relays,
153 fallback_signer_relays: opts.fallback_signer_relays, 158 fallback_signer_relays: opts.fallback_signer_relays,
159 grasp_default_set: opts.grasp_default_set,
154 relays_not_to_retry: Arc::new(RwLock::new(HashMap::new())), 160 relays_not_to_retry: Arc::new(RwLock::new(HashMap::new())),
155 } 161 }
156 } 162 }
@@ -189,8 +195,8 @@ impl Connect for Client {
189 Ok(()) 195 Ok(())
190 } 196 }
191 197
192 fn get_fallback_relays(&self) -> &Vec<String> { 198 fn get_relay_default_set(&self) -> &Vec<String> {
193 &self.fallback_relays 199 &self.relay_default_set
194 } 200 }
195 201
196 fn get_more_fallback_relays(&self) -> &Vec<String> { 202 fn get_more_fallback_relays(&self) -> &Vec<String> {
@@ -205,6 +211,10 @@ impl Connect for Client {
205 &self.fallback_signer_relays 211 &self.fallback_signer_relays
206 } 212 }
207 213
214 fn get_grasp_default_set(&self) -> &Vec<String> {
215 &self.grasp_default_set
216 }
217
208 async fn send_event_to<'a>( 218 async fn send_event_to<'a>(
209 &self, 219 &self,
210 git_repo_path: Option<&'a Path>, 220 git_repo_path: Option<&'a Path>,
@@ -335,8 +345,8 @@ impl Connect for Client {
335 trusted_maintainer_coordinate: Option<&'a Nip19Coordinate>, 345 trusted_maintainer_coordinate: Option<&'a Nip19Coordinate>,
336 user_profiles: &HashSet<PublicKey>, 346 user_profiles: &HashSet<PublicKey>,
337 ) -> Result<(Vec<Result<FetchReport>>, MultiProgress)> { 347 ) -> Result<(Vec<Result<FetchReport>>, MultiProgress)> {
338 let fallback_relays = &self 348 let relay_default_set = &self
339 .fallback_relays 349 .relay_default_set
340 .iter() 350 .iter()
341 .filter_map(|r| RelayUrl::parse(r).ok()) 351 .filter_map(|r| RelayUrl::parse(r).ok())
342 .collect::<HashSet<RelayUrl>>(); 352 .collect::<HashSet<RelayUrl>>();
@@ -345,7 +355,7 @@ impl Connect for Client {
345 git_repo_path, 355 git_repo_path,
346 trusted_maintainer_coordinate, 356 trusted_maintainer_coordinate,
347 user_profiles, 357 user_profiles,
348 fallback_relays.clone(), 358 relay_default_set.clone(),
349 ) 359 )
350 .await?; 360 .await?;
351 361
@@ -685,17 +695,18 @@ async fn get_events_of(
685 695
686pub struct Params { 696pub struct Params {
687 pub keys: Option<nostr::Keys>, 697 pub keys: Option<nostr::Keys>,
688 pub fallback_relays: Vec<String>, 698 pub relay_default_set: Vec<String>,
689 pub more_fallback_relays: Vec<String>, 699 pub more_fallback_relays: Vec<String>,
690 pub blaster_relays: Vec<String>, 700 pub blaster_relays: Vec<String>,
691 pub fallback_signer_relays: Vec<String>, 701 pub fallback_signer_relays: Vec<String>,
702 pub grasp_default_set: Vec<String>,
692} 703}
693 704
694impl Default for Params { 705impl Default for Params {
695 fn default() -> Self { 706 fn default() -> Self {
696 Params { 707 Params {
697 keys: None, 708 keys: None,
698 fallback_relays: if std::env::var("NGITTEST").is_ok() { 709 relay_default_set: if std::env::var("NGITTEST").is_ok() {
699 vec![ 710 vec![
700 "ws://localhost:8051".to_string(), 711 "ws://localhost:8051".to_string(),
701 "ws://localhost:8052".to_string(), 712 "ws://localhost:8052".to_string(),
@@ -731,6 +742,11 @@ impl Default for Params {
731 } else { 742 } else {
732 vec!["wss://relay.nsec.app".to_string()] 743 vec!["wss://relay.nsec.app".to_string()]
733 }, 744 },
745 grasp_default_set: if std::env::var("NGITTEST").is_ok() {
746 vec![]
747 } else {
748 vec!["relay.ngit.dev".to_string(), "gitnostr.com".to_string()]
749 },
734 } 750 }
735 } 751 }
736} 752}
@@ -749,7 +765,7 @@ impl Params {
749 .collect(); 765 .collect();
750 // elsewhere it is assumed this isn't empty 766 // elsewhere it is assumed this isn't empty
751 if !new_default_relays.is_empty() { 767 if !new_default_relays.is_empty() {
752 params.fallback_relays = new_default_relays; 768 params.relay_default_set = new_default_relays;
753 } 769 }
754 } 770 }
755 if let Ok(Some(relay_blasters)) = 771 if let Ok(Some(relay_blasters)) =
@@ -770,6 +786,17 @@ impl Params {
770 .map(|relay_url| relay_url.to_string()) // Convert RelayUrl back to String 786 .map(|relay_url| relay_url.to_string()) // Convert RelayUrl back to String
771 .collect(); 787 .collect();
772 } 788 }
789 if let Ok(Some(grasp_default_servers)) =
790 get_git_config_item(git_repo, "nostr.grasp-default-set")
791 {
792 let new_default_grasp_servers: Vec<String> = grasp_default_servers
793 .split(';')
794 .filter_map(|url| normalize_grasp_server_url(url).ok()) // Attempt to parse and filter out errors
795 .collect();
796 if !new_default_grasp_servers.is_empty() {
797 params.grasp_default_set = new_default_grasp_servers;
798 }
799 }
773 } 800 }
774 params 801 params
775 } 802 }
@@ -811,6 +838,30 @@ pub async fn sign_event(
811 } 838 }
812} 839}
813 840
841pub async fn sign_draft_event(
842 draft_event: UnsignedEvent,
843 signer: &Arc<dyn NostrSigner>,
844 description: String,
845) -> Result<nostr::Event> {
846 if signer.backend() == SignerBackend::NostrConnect {
847 let term = console::Term::stderr();
848 term.write_line(&format!(
849 "signing event ({description}) with remote signer..."
850 ))?;
851 let event = signer
852 .sign_event(draft_event)
853 .await
854 .context("failed to sign event")?;
855 term.clear_last_lines(1)?;
856 Ok(event)
857 } else {
858 signer
859 .sign_event(draft_event)
860 .await
861 .context("failed to sign event")
862 }
863}
864
814pub async fn fetch_public_key(signer: &Arc<dyn NostrSigner>) -> Result<nostr::PublicKey> { 865pub async fn fetch_public_key(signer: &Arc<dyn NostrSigner>) -> Result<nostr::PublicKey> {
815 if signer.backend() == SignerBackend::NostrConnect { 866 if signer.backend() == SignerBackend::NostrConnect {
816 let term = console::Term::stderr(); 867 let term = console::Term::stderr();
@@ -1459,7 +1510,7 @@ async fn process_fetched_events(
1459 report.updated_state = Some((event.created_at, event.id)); 1510 report.updated_state = Some((event.created_at, event.id));
1460 } 1511 }
1461 } 1512 }
1462 } else if event_is_patch_set_root(event) { 1513 } else if event_is_patch_set_root(event) || event.kind.eq(&KIND_PULL_REQUEST) {
1463 fresh_proposal_roots.insert(event.id); 1514 fresh_proposal_roots.insert(event.id);
1464 report.proposals.insert(event.id); 1515 report.proposals.insert(event.id);
1465 if !request.contributors.contains(&event.pubkey) 1516 if !request.contributors.contains(&event.pubkey)
@@ -1487,12 +1538,23 @@ async fn process_fetched_events(
1487 } 1538 }
1488 for event in &events { 1539 for event in &events {
1489 if !request.existing_events.contains(&event.id) 1540 if !request.existing_events.contains(&event.id)
1490 && !event 1541 && (!event
1491 .tags 1542 .tags
1492 .event_ids() 1543 .event_ids()
1493 .any(|id| report.proposals.contains(id)) 1544 .any(|id| report.proposals.contains(id))
1545 || event
1546 .tags
1547 .filter_standardized(TagKind::Custom(std::borrow::Cow::Borrowed("E")))
1548 .filter_map(|t| match t {
1549 TagStandard::Event { event_id, .. } => Some(event_id),
1550 TagStandard::EventReport(event_id, ..) => Some(event_id),
1551 _ => None,
1552 })
1553 .any(|id| report.proposals.contains(id)))
1494 { 1554 {
1495 if event.kind.eq(&Kind::GitPatch) && !event_is_patch_set_root(event) { 1555 if (event.kind.eq(&Kind::GitPatch) && !event_is_patch_set_root(event))
1556 || event.kind.eq(&KIND_PULL_REQUEST_UPDATE)
1557 {
1496 report.commits.insert(event.id); 1558 report.commits.insert(event.id);
1497 } else if status_kinds().contains(&event.kind) { 1559 } else if status_kinds().contains(&event.kind) {
1498 report.statuses.insert(event.id); 1560 report.statuses.insert(event.id);
@@ -1570,7 +1632,7 @@ pub fn get_fetch_filters(
1570 get_filter_state_events(repo_coordinates), 1632 get_filter_state_events(repo_coordinates),
1571 get_filter_repo_events(repo_coordinates), 1633 get_filter_repo_events(repo_coordinates),
1572 nostr::Filter::default() 1634 nostr::Filter::default()
1573 .kinds(vec![Kind::GitPatch, Kind::EventDeletion]) 1635 .kinds(vec![Kind::GitPatch, Kind::EventDeletion, KIND_PULL_REQUEST])
1574 .custom_tags( 1636 .custom_tags(
1575 SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), 1637 SingleLetterTag::lowercase(nostr_sdk::Alphabet::A),
1576 repo_coordinates 1638 repo_coordinates
@@ -1584,15 +1646,29 @@ pub fn get_fetch_filters(
1584 vec![] 1646 vec![]
1585 } else { 1647 } else {
1586 vec![ 1648 vec![
1587 nostr::Filter::default() 1649 nostr::Filter::default().events(proposal_ids.clone()).kinds(
1588 .events(proposal_ids.clone()) 1650 [
1589 .kinds([vec![Kind::GitPatch, Kind::EventDeletion], status_kinds()].concat()), 1651 vec![
1652 Kind::GitPatch,
1653 Kind::EventDeletion,
1654 KIND_PULL_REQUEST_UPDATE,
1655 ],
1656 status_kinds(),
1657 ]
1658 .concat(),
1659 ),
1590 nostr::Filter::default() 1660 nostr::Filter::default()
1591 .custom_tags( 1661 .custom_tags(
1592 SingleLetterTag::uppercase(Alphabet::E), 1662 SingleLetterTag::uppercase(Alphabet::E),
1593 proposal_ids.clone(), 1663 proposal_ids.clone(),
1594 ) 1664 )
1595 .kinds([vec![Kind::GitPatch, Kind::EventDeletion], status_kinds()].concat()), 1665 .kinds(
1666 [
1667 vec![Kind::EventDeletion, KIND_PULL_REQUEST_UPDATE],
1668 status_kinds(),
1669 ]
1670 .concat(),
1671 ),
1596 ] 1672 ]
1597 }, 1673 },
1598 if required_profiles.is_empty() { 1674 if required_profiles.is_empty() {
@@ -1784,7 +1860,7 @@ pub async fn get_proposals_and_revisions_from_cache(
1784 git_repo_path, 1860 git_repo_path,
1785 vec![ 1861 vec![
1786 nostr::Filter::default() 1862 nostr::Filter::default()
1787 .kind(nostr::Kind::GitPatch) 1863 .kinds([nostr::Kind::GitPatch, KIND_PULL_REQUEST])
1788 .custom_tags( 1864 .custom_tags(
1789 nostr::SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), 1865 nostr::SingleLetterTag::lowercase(nostr_sdk::Alphabet::A),
1790 repo_coordinates 1866 repo_coordinates
@@ -1796,7 +1872,8 @@ pub async fn get_proposals_and_revisions_from_cache(
1796 ) 1872 )
1797 .await? 1873 .await?
1798 .iter() 1874 .iter()
1799 .filter(|e| event_is_patch_set_root(e)) 1875 .filter(|e| event_is_patch_set_root(e) || e.kind.eq(&KIND_PULL_REQUEST))
1876 .filter(|e| e.kind.eq(&Kind::GitPatch) || event_is_valid_pr_or_pr_update(e))
1800 .cloned() 1877 .cloned()
1801 .collect::<Vec<nostr::Event>>(); 1878 .collect::<Vec<nostr::Event>>();
1802 proposals.sort_by_key(|e| e.created_at); 1879 proposals.sort_by_key(|e| e.created_at);
@@ -1804,7 +1881,7 @@ pub async fn get_proposals_and_revisions_from_cache(
1804 Ok(proposals) 1881 Ok(proposals)
1805} 1882}
1806 1883
1807pub async fn get_all_proposal_patch_events_from_cache( 1884pub async fn get_all_proposal_patch_pr_pr_update_events_from_cache(
1808 git_repo_path: &Path, 1885 git_repo_path: &Path,
1809 repo_ref: &RepoRef, 1886 repo_ref: &RepoRef,
1810 proposal_id: &nostr::EventId, 1887 proposal_id: &nostr::EventId,
@@ -1813,10 +1890,21 @@ pub async fn get_all_proposal_patch_events_from_cache(
1813 git_repo_path, 1890 git_repo_path,
1814 vec![ 1891 vec![
1815 nostr::Filter::default() 1892 nostr::Filter::default()
1816 .kind(nostr::Kind::GitPatch) 1893 .kinds([
1894 nostr::Kind::GitPatch,
1895 KIND_PULL_REQUEST,
1896 KIND_PULL_REQUEST_UPDATE,
1897 ])
1817 .event(*proposal_id), 1898 .event(*proposal_id),
1818 nostr::Filter::default() 1899 nostr::Filter::default()
1819 .kind(nostr::Kind::GitPatch) 1900 .kinds([
1901 nostr::Kind::GitPatch,
1902 KIND_PULL_REQUEST,
1903 KIND_PULL_REQUEST_UPDATE,
1904 ])
1905 .custom_tag(SingleLetterTag::uppercase(Alphabet::E), *proposal_id),
1906 nostr::Filter::default()
1907 .kinds([nostr::Kind::GitPatch, KIND_PULL_REQUEST])
1820 .id(*proposal_id), 1908 .id(*proposal_id),
1821 ], 1909 ],
1822 ) 1910 )
@@ -1836,7 +1924,11 @@ pub async fn get_all_proposal_patch_events_from_cache(
1836 .iter() 1924 .iter()
1837 .copied() 1925 .copied()
1838 .collect(); 1926 .collect();
1839 commit_events.retain(|e| permissioned_users.contains(&e.pubkey)); 1927
1928 commit_events.retain(|e| {
1929 permissioned_users.contains(&e.pubkey)
1930 && (e.kind.eq(&Kind::GitPatch) || event_is_valid_pr_or_pr_update(e))
1931 });
1840 1932
1841 let revision_roots: HashSet<nostr::EventId> = commit_events 1933 let revision_roots: HashSet<nostr::EventId> = commit_events
1842 .iter() 1934 .iter()
@@ -1849,8 +1941,20 @@ pub async fn get_all_proposal_patch_events_from_cache(
1849 git_repo_path, 1941 git_repo_path,
1850 vec![ 1942 vec![
1851 nostr::Filter::default() 1943 nostr::Filter::default()
1852 .kind(nostr::Kind::GitPatch) 1944 .kinds([
1853 .events(revision_roots) 1945 nostr::Kind::GitPatch,
1946 KIND_PULL_REQUEST,
1947 KIND_PULL_REQUEST_UPDATE,
1948 ])
1949 .events(revision_roots.clone())
1950 .authors(permissioned_users.clone()),
1951 nostr::Filter::default()
1952 .kinds([
1953 nostr::Kind::GitPatch,
1954 KIND_PULL_REQUEST,
1955 KIND_PULL_REQUEST_UPDATE,
1956 ])
1957 .custom_tags(SingleLetterTag::uppercase(Alphabet::E), revision_roots)
1854 .authors(permissioned_users.clone()), 1958 .authors(permissioned_users.clone()),
1855 ], 1959 ],
1856 ) 1960 )
@@ -1891,7 +1995,7 @@ pub async fn send_events(
1891 silent: bool, 1995 silent: bool,
1892) -> Result<()> { 1996) -> Result<()> {
1893 let fallback = [ 1997 let fallback = [
1894 client.get_fallback_relays().clone(), 1998 client.get_relay_default_set().clone(),
1895 if events.iter().any(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) { 1999 if events.iter().any(|e| e.kind.eq(&Kind::GitRepoAnnouncement)) {
1896 client.get_blaster_relays().clone() 2000 client.get_blaster_relays().clone()
1897 } else { 2001 } else {
diff --git a/src/lib/git/mod.rs b/src/lib/git/mod.rs
index d4bf2f5..b275b49 100644
--- a/src/lib/git/mod.rs
+++ b/src/lib/git/mod.rs
@@ -10,6 +10,7 @@ use nostr_sdk::{
10 Tags, 10 Tags,
11 hashes::{Hash, sha1::Hash as Sha1Hash}, 11 hashes::{Hash, sha1::Hash as Sha1Hash},
12}; 12};
13use nostr_url::NostrUrlDecoded;
13 14
14use crate::git_events::{get_commit_id_from_patch, tag_value}; 15use crate::git_events::{get_commit_id_from_patch, tag_value};
15pub mod identify_ahead_behind; 16pub mod identify_ahead_behind;
@@ -92,6 +93,10 @@ pub trait RepoActions {
92 fn get_git_config_item(&self, item: &str, global: Option<bool>) -> Result<Option<String>>; 93 fn get_git_config_item(&self, item: &str, global: Option<bool>) -> Result<Option<String>>;
93 fn save_git_config_item(&self, item: &str, value: &str, global: bool) -> Result<()>; 94 fn save_git_config_item(&self, item: &str, value: &str, global: bool) -> Result<()>;
94 fn remove_git_config_item(&self, item: &str, global: bool) -> Result<bool>; 95 fn remove_git_config_item(&self, item: &str, global: bool) -> Result<bool>;
96 #[allow(async_fn_in_trait)]
97 async fn get_first_nostr_remote_when_in_ngit_binary(
98 &self,
99 ) -> Result<Option<(String, NostrUrlDecoded)>>;
95} 100}
96 101
97impl RepoActions for Repo { 102impl RepoActions for Repo {
@@ -796,6 +801,21 @@ impl RepoActions for Repo {
796 Ok(true) 801 Ok(true)
797 } 802 }
798 } 803 }
804
805 async fn get_first_nostr_remote_when_in_ngit_binary(
806 &self,
807 ) -> Result<Option<(String, NostrUrlDecoded)>> {
808 for remote_name in self.git_repo.remotes()?.iter().flatten() {
809 if let Some(remote_url) = self.git_repo.find_remote(remote_name)?.url() {
810 if let Ok(nostr_url_decoded) =
811 NostrUrlDecoded::parse_and_resolve(remote_url, &Some(self)).await
812 {
813 return Ok(Some((remote_name.to_string(), nostr_url_decoded)));
814 }
815 }
816 }
817 Ok(None)
818 }
799} 819}
800 820
801fn oid_to_u8_20_bytes(oid: &Oid) -> [u8; 20] { 821fn oid_to_u8_20_bytes(oid: &Oid) -> [u8; 20] {
diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs
index 69406c1..2e1f215 100644
--- a/src/lib/git_events.rs
+++ b/src/lib/git_events.rs
@@ -1,7 +1,10 @@
1use std::{str::FromStr, sync::Arc}; 1use std::{str::FromStr, sync::Arc};
2 2
3use anyhow::{Context, Result, bail}; 3use anyhow::{Context, Result, bail};
4use nostr::nips::{nip01::Coordinate, nip10::Marker, nip19::Nip19}; 4use nostr::{
5 event::UnsignedEvent,
6 nips::{nip01::Coordinate, nip10::Marker, nip19::Nip19},
7};
5use nostr_sdk::{ 8use nostr_sdk::{
6 Event, EventBuilder, EventId, FromBech32, Kind, NostrSigner, PublicKey, Tag, TagKind, 9 Event, EventBuilder, EventId, FromBech32, Kind, NostrSigner, PublicKey, Tag, TagKind,
7 TagStandard, hashes::sha1::Hash as Sha1Hash, 10 TagStandard, hashes::sha1::Hash as Sha1Hash,
@@ -58,6 +61,9 @@ pub fn status_kinds() -> Vec<Kind> {
58 ] 61 ]
59} 62}
60 63
64pub const KIND_PULL_REQUEST: Kind = Kind::Custom(1618);
65pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::Custom(1619);
66
61pub fn event_is_patch_set_root(event: &Event) -> bool { 67pub fn event_is_patch_set_root(event: &Event) -> bool {
62 event.kind.eq(&Kind::GitPatch) 68 event.kind.eq(&Kind::GitPatch)
63 && event 69 && event
@@ -67,11 +73,16 @@ pub fn event_is_patch_set_root(event: &Event) -> bool {
67} 73}
68 74
69pub fn event_is_revision_root(event: &Event) -> bool { 75pub fn event_is_revision_root(event: &Event) -> bool {
70 event.kind.eq(&Kind::GitPatch) 76 (event.kind.eq(&Kind::GitPatch)
71 && event 77 && event
72 .tags 78 .tags
73 .iter() 79 .iter()
74 .any(|t| t.as_slice().len() > 1 && t.as_slice()[1].eq("revision-root")) 80 .any(|t| t.as_slice().len() > 1 && t.as_slice()[1].eq("revision-root")))
81 || (event.kind.eq(&KIND_PULL_REQUEST)
82 && event
83 .tags
84 .iter()
85 .any(|t| t.as_slice().len() > 1 && t.as_slice()[0].eq("e")))
75} 86}
76 87
77pub fn patch_supports_commit_ids(event: &Event) -> bool { 88pub fn patch_supports_commit_ids(event: &Event) -> bool {
@@ -82,6 +93,19 @@ pub fn patch_supports_commit_ids(event: &Event) -> bool {
82 .any(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq("commit-pgp-sig")) 93 .any(|t| !t.as_slice().is_empty() && t.as_slice()[0].eq("commit-pgp-sig"))
83} 94}
84 95
96pub fn event_is_valid_pr_or_pr_update(event: &Event) -> bool {
97 [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&event.kind)
98 && event.tags.iter().any(|t| {
99 t.as_slice().len().gt(&1)
100 && t.as_slice()[0].eq("c")
101 && git2::Oid::from_str(&t.as_slice()[1]).is_ok()
102 })
103 && event
104 .tags
105 .iter()
106 .any(|t| t.as_slice().len().gt(&1) && t.as_slice()[0].eq("clone"))
107}
108
85#[allow(clippy::too_many_arguments)] 109#[allow(clippy::too_many_arguments)]
86#[allow(clippy::too_many_lines)] 110#[allow(clippy::too_many_lines)]
87pub async fn generate_patch_event( 111pub async fn generate_patch_event(
@@ -326,6 +350,180 @@ pub fn event_tag_from_nip19_or_hex(
326 } 350 }
327} 351}
328 352
353pub fn generate_unsigned_pr_or_update_event(
354 git_repo: &Repo,
355 repo_ref: &RepoRef,
356 signing_public_key: &PublicKey,
357 root_proposal: Option<&Event>,
358 commit: &Sha1Hash,
359 clone_url_hint: &[&str],
360 mentions: &[nostr::Tag],
361) -> Result<UnsignedEvent> {
362 let root_patch_cover_letter = if let Some(root_proposal) = root_proposal {
363 if root_proposal.kind.eq(&Kind::GitPatch) {
364 Some(event_to_cover_letter(root_proposal)?)
365 } else {
366 None
367 }
368 } else {
369 None
370 };
371
372 let title = if let Some(cl) = &root_patch_cover_letter {
373 cl.title.clone()
374 } else {
375 git_repo.get_commit_message_summary(commit)?
376 };
377
378 let description = if let Some(cl) = &root_patch_cover_letter {
379 cl.description.clone()
380 } else {
381 let mut description = git_repo.get_commit_message(commit)?.trim().to_string();
382 if let Some(remaining_description) = description.strip_prefix(&title) {
383 description = remaining_description.trim().to_string();
384 }
385 description
386 };
387
388 let root_commit = git_repo
389 .get_root_commit()
390 .context("failed to get root commit of the repository")?;
391
392 let pr_update_specific_tags = |root_proposal: &Event| {
393 vec![
394 Tag::custom(
395 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
396 vec![format!("git Pull Request Update")],
397 ),
398 Tag::custom(
399 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("E")),
400 vec![root_proposal.id],
401 ),
402 Tag::custom(
403 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("P")),
404 vec![root_proposal.pubkey],
405 ),
406 ]
407 };
408 let pr_specific_tags = || {
409 [
410 vec![
411 Tag::from_standardized(TagStandard::Subject(title.clone())),
412 Tag::custom(
413 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
414 vec![format!("git Pull Request: {}", title.clone())],
415 ),
416 ],
417 if let Some(cl) = &root_patch_cover_letter {
418 vec![
419 Tag::custom(
420 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("e")),
421 vec![root_proposal.unwrap().id],
422 ),
423 Tag::custom(
424 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
425 vec![cl.branch_name_without_id_or_prefix.clone()],
426 ),
427 Tag::public_key(root_proposal.unwrap().pubkey),
428 ]
429 } else if let Some(branch_name_tag) =
430 make_branch_name_tag_from_check_out_branch(git_repo)
431 {
432 vec![branch_name_tag]
433 } else {
434 vec![]
435 },
436 ]
437 .concat()
438 };
439
440 Ok(
441 if root_proposal.is_some() && root_patch_cover_letter.is_none() {
442 EventBuilder::new(KIND_PULL_REQUEST_UPDATE, "")
443 } else {
444 EventBuilder::new(KIND_PULL_REQUEST, description)
445 }
446 .tags(
447 [
448 repo_ref
449 .maintainers
450 .iter()
451 .map(|m| {
452 Tag::from_standardized(TagStandard::Coordinate {
453 coordinate: Coordinate {
454 kind: nostr::Kind::GitRepoAnnouncement,
455 public_key: *m,
456 identifier: repo_ref.identifier.to_string(),
457 },
458 relay_url: repo_ref.relays.first().cloned(),
459 uppercase: false,
460 })
461 })
462 .collect::<Vec<Tag>>(),
463 mentions.to_vec(),
464 if let Some(root_proposal) = root_proposal {
465 if root_patch_cover_letter.is_none() {
466 pr_update_specific_tags(root_proposal)
467 } else {
468 pr_specific_tags()
469 }
470 } else {
471 pr_specific_tags()
472 },
473 vec![
474 Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))),
475 Tag::custom(
476 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("c")),
477 vec![format!("{commit}")],
478 ),
479 Tag::custom(
480 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("clone")),
481 clone_url_hint
482 .iter()
483 .map(|s| s.to_string())
484 .collect::<Vec<String>>(),
485 ),
486 ],
487 repo_ref
488 .maintainers
489 .iter()
490 .map(|pk| Tag::public_key(*pk))
491 .collect(),
492 ]
493 .concat(),
494 )
495 .build(*signing_public_key),
496 )
497}
498
499fn make_branch_name_tag_from_check_out_branch(git_repo: &Repo) -> Option<Tag> {
500 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
501 if !branch_name.eq("main")
502 && !branch_name.eq("master")
503 && !branch_name.eq("origin/main")
504 && !branch_name.eq("origin/master")
505 {
506 Some(Tag::custom(
507 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
508 vec![
509 if let Some(branch_name) = branch_name.strip_prefix("pr/") {
510 branch_name.to_string()
511 } else {
512 branch_name
513 }
514 .chars()
515 .take(60)
516 .collect::<String>(),
517 ],
518 ))
519 } else {
520 None
521 }
522 } else {
523 None
524 }
525}
526
329#[allow(clippy::too_many_lines)] 527#[allow(clippy::too_many_lines)]
330pub async fn generate_cover_letter_and_patch_events( 528pub async fn generate_cover_letter_and_patch_events(
331 cover_letter_title_description: Option<(String, String)>, 529 cover_letter_title_description: Option<(String, String)>,
@@ -388,24 +586,8 @@ pub async fn generate_cover_letter_and_patch_events(
388 // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding 586 // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding
389 // a change like this, or the removal of this tag will require the actual branch name to be tracked 587 // a change like this, or the removal of this tag will require the actual branch name to be tracked
390 // so pulling and pushing still work 588 // so pulling and pushing still work
391 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { 589 if let Some(branch_name_tag) = make_branch_name_tag_from_check_out_branch(git_repo) {
392 if !branch_name.eq("main") 590 vec![branch_name_tag]
393 && !branch_name.eq("master")
394 && !branch_name.eq("origin/main")
395 && !branch_name.eq("origin/master")
396 {
397 vec![
398 Tag::custom(
399 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
400 vec![if let Some(branch_name) = branch_name.strip_prefix("pr/") {
401 branch_name.to_string()
402 } else {
403 branch_name
404 }.chars().take(60).collect::<String>()],
405 ),
406 ]
407 }
408 else { vec![] }
409 } else { 591 } else {
410 vec![] 592 vec![]
411 }, 593 },
@@ -531,13 +713,22 @@ pub fn commit_msg_from_patch_oneliner(patch: &nostr::Event) -> Result<String> {
531} 713}
532 714
533pub fn event_to_cover_letter(event: &nostr::Event) -> Result<CoverLetter> { 715pub fn event_to_cover_letter(event: &nostr::Event) -> Result<CoverLetter> {
534 if !event_is_patch_set_root(event) { 716 if !event.kind.eq(&KIND_PULL_REQUEST) && !event_is_patch_set_root(event) {
535 bail!("event is not a patch set root event (root patch or cover letter)") 717 bail!("event is not a patch set root event (root patch or cover letter)")
536 } 718 }
537 719
538 let title = commit_msg_from_patch_oneliner(event)?; 720 let title = if event.kind.eq(&KIND_PULL_REQUEST) {
539 let full = commit_msg_from_patch(event)?; 721 tag_value(event, "subject").unwrap_or("untitled".to_owned())
540 let description = full[title.len()..].trim().to_string(); 722 } else {
723 commit_msg_from_patch_oneliner(event)?
724 };
725 let description = if event.kind.eq(&KIND_PULL_REQUEST) {
726 event.content.clone()
727 } else {
728 commit_msg_from_patch(event)?[title.len()..]
729 .trim()
730 .to_string()
731 };
541 732
542 Ok(CoverLetter { 733 Ok(CoverLetter {
543 title: title.clone(), 734 title: title.clone(),
@@ -569,25 +760,25 @@ fn safe_branch_name_for_pr(s: &str) -> String {
569 .collect() 760 .collect()
570} 761}
571 762
572pub fn get_most_recent_patch_with_ancestors( 763pub fn get_pr_tip_event_or_most_recent_patch_with_ancestors(
573 mut patches: Vec<nostr::Event>, 764 mut proposal_events: Vec<nostr::Event>,
574) -> Result<Vec<nostr::Event>> { 765) -> Result<Vec<nostr::Event>> {
575 patches.sort_by_key(|e| e.created_at); 766 proposal_events.sort_by_key(|e| e.created_at);
576 767
577 let youngest_patch = patches.last().context("no patches found")?; 768 let youngest = proposal_events.last().context("no proposal events found")?;
578 769
579 let patches_with_youngest_created_at: Vec<&nostr::Event> = patches 770 let events_with_youngest_created_at: Vec<&nostr::Event> = proposal_events
580 .iter() 771 .iter()
581 .filter(|p| p.created_at.eq(&youngest_patch.created_at)) 772 .filter(|p| p.created_at.eq(&youngest.created_at))
582 .collect(); 773 .collect();
583 774
584 let mut res = vec![]; 775 let mut res = vec![];
585 776
586 let mut event_id_to_search = patches_with_youngest_created_at 777 let mut event_id_to_search = events_with_youngest_created_at
587 .clone() 778 .clone()
588 .iter() 779 .iter()
589 .find(|p| { 780 .find(|p| {
590 !patches_with_youngest_created_at.iter().any(|p2| { 781 !events_with_youngest_created_at.iter().any(|p2| {
591 if let Ok(reply_to) = get_event_parent_id(p2) { 782 if let Ok(reply_to) = get_event_parent_id(p2) {
592 reply_to.eq(&p.id.to_string()) 783 reply_to.eq(&p.id.to_string())
593 } else { 784 } else {
@@ -595,16 +786,18 @@ pub fn get_most_recent_patch_with_ancestors(
595 } 786 }
596 }) 787 })
597 }) 788 })
598 .context("failed to find patches_with_youngest_created_at")? 789 .context("failed to find events_with_youngest_created_at")?
599 .id 790 .id
600 .to_string(); 791 .to_string();
601 792
602 while let Some(event) = patches 793 while let Some(event) = proposal_events
603 .iter() 794 .iter()
604 .find(|e| e.id.to_string().eq(&event_id_to_search)) 795 .find(|e| e.id.to_string().eq(&event_id_to_search))
605 { 796 {
606 res.push(event.clone()); 797 res.push(event.clone());
607 if event_is_patch_set_root(event) { 798 if [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&event.kind)
799 || event_is_patch_set_root(event)
800 {
608 break; 801 break;
609 } 802 }
610 event_id_to_search = get_event_parent_id(event).unwrap_or_default(); 803 event_id_to_search = get_event_parent_id(event).unwrap_or_default();
@@ -642,7 +835,61 @@ pub fn is_event_proposal_root_for_branch(
642 || cl 835 || cl
643 .get_branch_name_with_pr_prefix_and_shorthand_id() 836 .get_branch_name_with_pr_prefix_and_shorthand_id()
644 .is_ok_and(|s| s.eq(&branch_name)) 837 .is_ok_and(|s| s.eq(&branch_name))
645 }) && !event_is_revision_root(e)) 838 }) && (
839 // If we wanted to treat to list Pull Requests that revise a Patch we would do this:
840 // Note: whilst this the the case elsewhere event_is_revision_root is used, there is more to
841 // think about here?
842 // e.kind.eq(&KIND_PULL_REQUEST) ||
843 !event_is_revision_root(e)
844 ))
845}
846
847pub fn get_status(
848 proposal: &Event,
849 repo_ref: &RepoRef,
850 all_status_in_repo: &[Event],
851 all_pr_roots_in_repo: &[Event],
852) -> Kind {
853 let get_direct_status = |proposal: &Event| {
854 if let Some(e) = all_status_in_repo
855 .iter()
856 .filter(|e| {
857 status_kinds().contains(&e.kind)
858 && e.tags.iter().any(|t| {
859 t.as_slice().len() > 1 && t.as_slice()[1].eq(&proposal.id.to_string())
860 })
861 && (proposal.pubkey.eq(&e.pubkey) || repo_ref.maintainers.contains(&e.pubkey))
862 })
863 .collect::<Vec<&nostr::Event>>()
864 .first()
865 {
866 e.kind
867 } else {
868 Kind::GitStatusOpen
869 }
870 };
871 let is_proposal_pr_revision_of_patch = |proposal: &Event, patch: &Event| {
872 proposal.kind.eq(&KIND_PULL_REQUEST)
873 && proposal.tags.clone().into_iter().any(|t| {
874 t.as_slice().len() > 1
875 && t.as_slice()[0].eq("e")
876 && t.as_slice()[1].eq(&patch.id.to_string())
877 })
878 };
879
880 let direct_status = get_direct_status(proposal);
881 if direct_status.eq(&Kind::GitStatusClosed) && proposal.kind.eq(&Kind::GitPatch) {
882 if let Some(pr_revision) = all_pr_roots_in_repo
883 .iter()
884 .find(|p| is_proposal_pr_revision_of_patch(p, proposal))
885 {
886 get_direct_status(pr_revision)
887 } else {
888 direct_status
889 }
890 } else {
891 direct_status
892 }
646} 893}
647 894
648#[cfg(test)] 895#[cfg(test)]
diff --git a/src/lib/login/fresh.rs b/src/lib/login/fresh.rs
index a169177..358045a 100644
--- a/src/lib/login/fresh.rs
+++ b/src/lib/login/fresh.rs
@@ -728,7 +728,7 @@ async fn signup(
728 EventBuilder::metadata(&Metadata::new().name(name)).sign_with_keys(&keys)?; 728 EventBuilder::metadata(&Metadata::new().name(name)).sign_with_keys(&keys)?;
729 let relay_list = EventBuilder::relay_list( 729 let relay_list = EventBuilder::relay_list(
730 client 730 client
731 .get_fallback_relays() 731 .get_relay_default_set()
732 .iter() 732 .iter()
733 .map(|s| (RelayUrl::parse(s).unwrap(), None)), 733 .map(|s| (RelayUrl::parse(s).unwrap(), None)),
734 ) 734 )
@@ -738,7 +738,7 @@ async fn signup(
738 client, 738 client,
739 None, 739 None,
740 vec![profile, relay_list], 740 vec![profile, relay_list],
741 client.get_fallback_relays().clone(), 741 client.get_relay_default_set().clone(),
742 vec![], 742 vec![],
743 true, 743 true,
744 false, 744 false,
diff --git a/src/lib/repo_ref.rs b/src/lib/repo_ref.rs
index 0236e34..bca4a3b 100644
--- a/src/lib/repo_ref.rs
+++ b/src/lib/repo_ref.rs
@@ -309,7 +309,7 @@ impl RepoRef {
309 } 309 }
310 310
311 pub fn grasp_servers(&self) -> Vec<String> { 311 pub fn grasp_servers(&self) -> Vec<String> {
312 detect_existing_grasp_servers(Some(self), &[], &[], &[], &self.identifier) 312 detect_existing_grasp_servers(Some(self), &[], &[], &self.identifier)
313 } 313 }
314} 314}
315 315
@@ -593,7 +593,6 @@ pub fn detect_existing_grasp_servers(
593 repo_ref: Option<&RepoRef>, 593 repo_ref: Option<&RepoRef>,
594 args_relays: &[String], 594 args_relays: &[String],
595 args_clone_url: &[String], 595 args_clone_url: &[String],
596 args_blossoms: &[String],
597 identifier: &str, 596 identifier: &str,
598) -> Vec<String> { 597) -> Vec<String> {
599 // Collect clone URLs from arguments or repo_ref 598 // Collect clone URLs from arguments or repo_ref
@@ -617,18 +616,6 @@ pub fn detect_existing_grasp_servers(
617 Vec::new() 616 Vec::new()
618 }; 617 };
619 618
620 // Collect blossom server URLs from arguments or repo_ref
621 let blossoms: Vec<Url> = if !args_blossoms.is_empty() {
622 args_blossoms
623 .iter()
624 .filter_map(|r| Url::parse(r).ok())
625 .collect()
626 } else if let Some(repo) = repo_ref {
627 repo.blossoms.clone()
628 } else {
629 Vec::new()
630 };
631
632 let mut existing_grasp_servers = Vec::new(); 619 let mut existing_grasp_servers = Vec::new();
633 for url in &clone_urls { 620 for url in &clone_urls {
634 let Ok(formatted_as_grasp_server_url) = normalize_grasp_server_url(url) else { 621 let Ok(formatted_as_grasp_server_url) = normalize_grasp_server_url(url) else {
@@ -655,14 +642,6 @@ pub fn detect_existing_grasp_servers(
655 continue; 642 continue;
656 } 643 }
657 644
658 let matches_blossoms = blossoms.iter().any(|r| {
659 normalize_grasp_server_url(r.as_str())
660 .is_ok_and(|r| r.eq(&formatted_as_grasp_server_url))
661 });
662 if !matches_blossoms {
663 continue;
664 }
665
666 existing_grasp_servers.push(formatted_as_grasp_server_url); 645 existing_grasp_servers.push(formatted_as_grasp_server_url);
667 } 646 }
668 existing_grasp_servers 647 existing_grasp_servers