upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/ngit/sub_commands/pr_merge.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 14:28:38 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-03-04 14:50:01 +0000
commitb3b1a949463d8e18622519866ecee3f1b65cc888 (patch)
tree9c728adb75fb18cb84e8d13efbbbd5e90231ec2f /src/bin/ngit/sub_commands/pr_merge.rs
parent6d9b0cc8fff65447849d0d55db177dcdff315c48 (diff)
restructure CLI around ngit pr/issue subcommand groups
Introduce ngit pr subcommand group (list, view, checkout, apply, send, close, reopen, ready, comment, merge) replacing the former top-level ngit list/checkout/apply commands. ngit send is kept at the top level. Expand ngit issue with view, create, close, reopen, comment subcommands. Status changes (close/reopen/ready) are gated to the PR/issue author or a repository maintainer. ngit pr merge is maintainer-only and publishes a GitStatusApplied event immediately after the git merge.
Diffstat (limited to 'src/bin/ngit/sub_commands/pr_merge.rs')
-rw-r--r--src/bin/ngit/sub_commands/pr_merge.rs249
1 files changed, 249 insertions, 0 deletions
diff --git a/src/bin/ngit/sub_commands/pr_merge.rs b/src/bin/ngit/sub_commands/pr_merge.rs
new file mode 100644
index 0000000..df00e7e
--- /dev/null
+++ b/src/bin/ngit/sub_commands/pr_merge.rs
@@ -0,0 +1,249 @@
1use anyhow::{Context, Result, bail};
2use ngit::{
3 client::{
4 Params, get_all_proposal_patch_pr_pr_update_events_from_cache,
5 get_proposals_and_revisions_from_cache, send_events, sign_event,
6 },
7 git_events::{
8 KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE,
9 get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, status_kinds, tag_value,
10 },
11};
12use nostr::{EventBuilder, Tag, TagStandard, nips::nip19::Nip19};
13use nostr_sdk::{EventId, FromBech32, Kind, nips::nip10::Marker};
14
15use crate::{
16 client::{
17 Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache,
18 },
19 git::{Repo, RepoActions, str_to_sha1},
20 git_events::event_to_cover_letter,
21 login,
22 repo_ref::get_repo_coordinates_when_remote_unknown,
23};
24
25fn parse_event_id(id: &str) -> Result<EventId> {
26 if let Ok(nip19) = Nip19::from_bech32(id) {
27 match nip19 {
28 nostr::nips::nip19::Nip19::Event(e) => return Ok(e.event_id),
29 nostr::nips::nip19::Nip19::EventId(event_id) => return Ok(event_id),
30 _ => {}
31 }
32 }
33 if let Ok(event_id) = EventId::from_hex(id) {
34 return Ok(event_id);
35 }
36 bail!("invalid event-id or nevent: {id}")
37}
38
39#[allow(clippy::too_many_lines)]
40pub async fn launch(id: &str, squash: bool, offline: bool) -> Result<()> {
41 let event_id = parse_event_id(id)?;
42
43 let git_repo = Repo::discover().context("failed to find a git repository")?;
44 let git_repo_path = git_repo.get_path()?;
45
46 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
47 let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?;
48
49 if !offline {
50 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
51 }
52
53 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
54
55 // Login to verify maintainer status
56 let (signer, user_ref, _) =
57 login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?;
58
59 let user_pubkey = signer.get_public_key().await?;
60
61 if !repo_ref.maintainers.contains(&user_pubkey) {
62 bail!("only a repository maintainer can merge a PR");
63 }
64
65 let proposals_and_revisions =
66 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?;
67
68 let proposal = proposals_and_revisions
69 .iter()
70 .find(|e| e.id == event_id)
71 .context(format!(
72 "PR with id {} not found in cache",
73 event_id.to_hex()
74 ))?
75 .clone();
76
77 // Check current status — only open/draft PRs can be merged
78 let statuses = {
79 let mut s = get_events_from_local_cache(
80 git_repo_path,
81 vec![
82 nostr::Filter::default()
83 .kinds(status_kinds().clone())
84 .events(proposals_and_revisions.iter().map(|e| e.id)),
85 nostr::Filter::default()
86 .custom_tags(
87 nostr::filter::SingleLetterTag::uppercase(nostr::filter::Alphabet::E),
88 proposals_and_revisions.iter().map(|e| e.id),
89 )
90 .kinds(status_kinds().clone()),
91 ],
92 )
93 .await?;
94 s.sort_by_key(|e| e.created_at);
95 s.reverse();
96 s
97 };
98
99 let proposals_vec: Vec<nostr::Event> = proposals_and_revisions
100 .iter()
101 .filter(|e| !ngit::git_events::event_is_revision_root(e))
102 .cloned()
103 .collect();
104
105 let current_status = get_status(&proposal, &repo_ref, &statuses, &proposals_vec);
106
107 if current_status == Kind::GitStatusApplied {
108 bail!("PR is already applied/merged");
109 }
110 if current_status == Kind::GitStatusClosed {
111 bail!("PR is closed; reopen it before merging");
112 }
113
114 let cover_letter = event_to_cover_letter(&proposal).context("failed to extract PR details")?;
115
116 let branch_name = cover_letter.get_branch_name_with_pr_prefix_and_shorthand_id()?;
117
118 // Get the PR tip commit
119 let commits_events = get_all_proposal_patch_pr_pr_update_events_from_cache(
120 git_repo_path,
121 &repo_ref,
122 &proposal.id,
123 )
124 .await?;
125
126 let tip_chain = get_pr_tip_event_or_most_recent_patch_with_ancestors(commits_events)
127 .context("failed to find any PR or patch events on this proposal")?;
128
129 let tip_commit_str = if tip_chain
130 .iter()
131 .any(|e| [KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(&e.kind))
132 {
133 let tip_event = tip_chain.first().context("tip chain is empty")?;
134 tag_value(tip_event, "c").context("PR event missing tip commit tag 'c'")?
135 } else {
136 ngit::git_events::get_commit_id_from_patch(
137 tip_chain.first().context("patch chain is empty")?,
138 )
139 .context("failed to get commit id from patch")?
140 };
141
142 let _tip_commit = str_to_sha1(&tip_commit_str).context("invalid tip commit OID")?;
143
144 // Ensure the branch exists locally
145 let local_branch_exists = git_repo
146 .get_local_branch_names()
147 .context("failed to get local branch names")?
148 .iter()
149 .any(|n| n.eq(&branch_name));
150
151 if !local_branch_exists {
152 // Try to create the branch at the tip commit
153 if !git_repo.does_commit_exist(&tip_commit_str)? {
154 bail!(
155 "PR tip commit {tip_commit_str} not found locally. Run `ngit pr checkout {id}` first."
156 );
157 }
158 git_repo.create_branch_at_commit(&branch_name, &tip_commit_str)?;
159 println!("created local branch '{branch_name}' at PR tip");
160 }
161
162 // Perform the git merge
163 let merge_args = if squash {
164 vec!["merge", "--squash", &branch_name]
165 } else {
166 vec!["merge", "--no-ff", &branch_name]
167 };
168
169 let output = std::process::Command::new("git")
170 .args(&merge_args)
171 .output()
172 .context("failed to run git merge")?;
173
174 if !output.status.success() {
175 let stderr = String::from_utf8_lossy(&output.stderr);
176 bail!("git merge failed:\n{stderr}");
177 }
178
179 let stdout = String::from_utf8_lossy(&output.stdout);
180 if !stdout.trim().is_empty() {
181 print!("{stdout}");
182 }
183
184 // Publish GitStatusApplied event
185 let mut public_keys: std::collections::HashSet<nostr::PublicKey> =
186 repo_ref.maintainers.iter().copied().collect();
187 public_keys.insert(proposal.pubkey);
188
189 let applied_event = sign_event(
190 EventBuilder::new(Kind::GitStatusApplied, "").tags(
191 [
192 vec![
193 Tag::custom(
194 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
195 vec!["PR merged".to_string()],
196 ),
197 Tag::from_standardized(TagStandard::Event {
198 event_id: proposal.id,
199 relay_url: repo_ref.relays.first().cloned(),
200 marker: Some(Marker::Root),
201 public_key: None,
202 uppercase: false,
203 }),
204 ],
205 public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(),
206 repo_ref
207 .coordinates()
208 .iter()
209 .map(|c| {
210 Tag::from_standardized(TagStandard::Coordinate {
211 coordinate: c.coordinate.clone(),
212 relay_url: c.relays.first().cloned(),
213 uppercase: false,
214 })
215 })
216 .collect::<Vec<Tag>>(),
217 vec![Tag::from_standardized(nostr::TagStandard::Reference(
218 repo_ref.root_commit.to_string(),
219 ))],
220 ]
221 .concat(),
222 ),
223 &signer,
224 "mark PR as applied".to_string(),
225 )
226 .await?;
227
228 let mut client = client;
229 client.set_signer(signer).await;
230
231 send_events(
232 &client,
233 Some(git_repo_path),
234 vec![applied_event],
235 user_ref.relays.write(),
236 repo_ref.relays.clone(),
237 true,
238 false,
239 )
240 .await?;
241
242 println!("PR '{}' merged and marked as applied", cover_letter.title);
243 println!(
244 "{}",
245 console::style("Push to update the nostr state: git push").yellow()
246 );
247
248 Ok(())
249}