upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/ngit/sub_commands
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
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')
-rw-r--r--src/bin/ngit/sub_commands/comment.rs182
-rw-r--r--src/bin/ngit/sub_commands/issue_create.rs122
-rw-r--r--src/bin/ngit/sub_commands/issue_status.rs178
-rw-r--r--src/bin/ngit/sub_commands/mod.rs5
-rw-r--r--src/bin/ngit/sub_commands/pr_merge.rs249
-rw-r--r--src/bin/ngit/sub_commands/pr_status.rs199
6 files changed, 935 insertions, 0 deletions
diff --git a/src/bin/ngit/sub_commands/comment.rs b/src/bin/ngit/sub_commands/comment.rs
new file mode 100644
index 0000000..a9b0aa7
--- /dev/null
+++ b/src/bin/ngit/sub_commands/comment.rs
@@ -0,0 +1,182 @@
1use anyhow::{Context, Result, bail};
2use ngit::{
3 client::{
4 Params, get_issues_from_cache, get_proposals_and_revisions_from_cache, send_events,
5 sign_event,
6 },
7 git_events::KIND_COMMENT,
8};
9use nostr::{EventBuilder, Tag, nips::nip19::Nip19};
10use nostr_sdk::{EventId, FromBech32, Kind};
11
12use crate::{
13 client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache},
14 git::{Repo, RepoActions},
15 login,
16 repo_ref::get_repo_coordinates_when_remote_unknown,
17};
18
19fn parse_event_id(id: &str) -> Result<EventId> {
20 if let Ok(nip19) = Nip19::from_bech32(id) {
21 match nip19 {
22 nostr::nips::nip19::Nip19::Event(e) => return Ok(e.event_id),
23 nostr::nips::nip19::Nip19::EventId(event_id) => return Ok(event_id),
24 _ => {}
25 }
26 }
27 if let Ok(event_id) = EventId::from_hex(id) {
28 return Ok(event_id);
29 }
30 bail!("invalid event-id or nevent: {id}")
31}
32
33/// Build and publish a NIP-22 kind-1111 comment on any event.
34///
35/// NIP-22 threading tags:
36/// - uppercase `E` — root event id
37/// - uppercase `K` — root event kind (as string)
38/// - lowercase `e` — parent event id (same as root for top-level comments)
39/// - lowercase `k` — parent event kind
40async fn publish_comment(
41 id: &str,
42 body: &str,
43 offline: bool,
44 root_kind: Kind,
45 entity_name: &str,
46) -> Result<()> {
47 let event_id = parse_event_id(id)?;
48
49 let git_repo = Repo::discover().context("failed to find a git repository")?;
50 let git_repo_path = git_repo.get_path()?;
51
52 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
53 let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?;
54
55 if !offline {
56 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
57 }
58
59 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
60
61 // Login
62 let (signer, user_ref, _) =
63 login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?;
64
65 let root_kind_str = root_kind.as_u16().to_string();
66
67 // NIP-22: uppercase E = root event, uppercase K = root kind,
68 // lowercase e = parent event (same as root for top-level),
69 // lowercase k = parent kind
70 let comment_event = sign_event(
71 EventBuilder::new(KIND_COMMENT, body).tags(vec![
72 // Root event (uppercase E)
73 Tag::parse(vec![
74 "E".to_string(),
75 event_id.to_hex(),
76 repo_ref
77 .relays
78 .first()
79 .map(ToString::to_string)
80 .unwrap_or_default(),
81 String::new(), // root marker
82 ])?,
83 // Root kind (uppercase K)
84 Tag::parse(vec!["K".to_string(), root_kind_str.clone()])?,
85 // Parent event (lowercase e, same as root for top-level comment)
86 Tag::parse(vec![
87 "e".to_string(),
88 event_id.to_hex(),
89 repo_ref
90 .relays
91 .first()
92 .map(ToString::to_string)
93 .unwrap_or_default(),
94 "reply".to_string(),
95 ])?,
96 // Parent kind (lowercase k)
97 Tag::parse(vec!["k".to_string(), root_kind_str])?,
98 ]),
99 &signer,
100 format!("comment on {entity_name}"),
101 )
102 .await?;
103
104 let mut client = client;
105 client.set_signer(signer).await;
106
107 send_events(
108 &client,
109 Some(git_repo_path),
110 vec![comment_event],
111 user_ref.relays.write(),
112 repo_ref.relays.clone(),
113 true,
114 false,
115 )
116 .await?;
117
118 println!(
119 "comment posted on {entity_name} {}",
120 &event_id.to_hex()[..8]
121 );
122 Ok(())
123}
124
125pub async fn launch_pr_comment(id: &str, body: &str, offline: bool) -> Result<()> {
126 // Verify the PR exists in cache
127 let event_id = parse_event_id(id)?;
128 let git_repo = Repo::discover().context("failed to find a git repository")?;
129 let git_repo_path = git_repo.get_path()?;
130 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
131 let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?;
132
133 if !offline {
134 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
135 }
136
137 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
138 let proposals =
139 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?;
140
141 let proposal = proposals
142 .iter()
143 .find(|e| e.id == event_id)
144 .context(format!(
145 "PR with id {} not found in cache",
146 event_id.to_hex()
147 ))?;
148
149 let root_kind = proposal.kind;
150
151 publish_comment(id, body, true /* already fetched */, root_kind, "PR").await
152}
153
154pub async fn launch_issue_comment(id: &str, body: &str, offline: bool) -> Result<()> {
155 // Verify the issue exists in cache
156 let event_id = parse_event_id(id)?;
157 let git_repo = Repo::discover().context("failed to find a git repository")?;
158 let git_repo_path = git_repo.get_path()?;
159 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
160 let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?;
161
162 if !offline {
163 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
164 }
165
166 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
167 let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?;
168
169 issues.iter().find(|e| e.id == event_id).context(format!(
170 "issue with id {} not found in cache",
171 event_id.to_hex()
172 ))?;
173
174 publish_comment(
175 id,
176 body,
177 true, /* already fetched */
178 Kind::GitIssue,
179 "issue",
180 )
181 .await
182}
diff --git a/src/bin/ngit/sub_commands/issue_create.rs b/src/bin/ngit/sub_commands/issue_create.rs
new file mode 100644
index 0000000..0c4b677
--- /dev/null
+++ b/src/bin/ngit/sub_commands/issue_create.rs
@@ -0,0 +1,122 @@
1use anyhow::{Context, Result, bail};
2use ngit::client::{Params, send_events, sign_event};
3use nostr::{EventBuilder, Tag, TagStandard, ToBech32, nips::nip19::Nip19Event};
4use nostr_sdk::Kind;
5
6use crate::{
7 client::{Client, Connect, fetching_with_report, get_repo_ref_from_cache},
8 git::{Repo, RepoActions},
9 login,
10 repo_ref::get_repo_coordinates_when_remote_unknown,
11};
12
13pub async fn launch(
14 title: Option<String>,
15 body: Option<String>,
16 labels: Vec<String>,
17) -> Result<()> {
18 let git_repo = Repo::discover().context("failed to find a git repository")?;
19 let git_repo_path = git_repo.get_path()?;
20
21 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
22 let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?;
23
24 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
25
26 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
27
28 // Resolve title — required
29 let title = match title {
30 Some(t) if !t.trim().is_empty() => t,
31 _ => bail!("--title is required to create an issue"),
32 };
33
34 // Body defaults to empty string if not provided
35 let body = body.unwrap_or_default();
36
37 // Login
38 let (signer, user_ref, _) =
39 login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?;
40
41 // Build NIP-34 GitIssue event (kind 1621)
42 // Tags:
43 // - `a` coordinate tags for each maintainer's repo announcement
44 // - `subject` — issue title
45 // - `t` — hashtag labels
46 // - `alt` — human-readable summary
47 let mut tags: Vec<Tag> = vec![];
48
49 // Repo coordinate tags (one per maintainer)
50 for coord in repo_ref.coordinates() {
51 tags.push(Tag::from_standardized(TagStandard::Coordinate {
52 coordinate: coord.coordinate.clone(),
53 relay_url: coord.relays.first().cloned(),
54 uppercase: false,
55 }));
56 }
57
58 // Subject (title)
59 tags.push(Tag::parse(vec!["subject".to_string(), title.clone()])?);
60
61 // Hashtag labels
62 for label in &labels {
63 tags.push(Tag::hashtag(label));
64 }
65
66 // Alt text
67 tags.push(Tag::custom(
68 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
69 vec![format!("git issue: {title}")],
70 ));
71
72 // Maintainer p-tags (so they get notified)
73 for pk in &repo_ref.maintainers {
74 tags.push(Tag::public_key(*pk));
75 }
76
77 let issue_event = sign_event(
78 EventBuilder::new(Kind::GitIssue, body).tags(tags),
79 &signer,
80 "create issue".to_string(),
81 )
82 .await?;
83
84 let event_id = issue_event.id;
85
86 let mut client = client;
87 client.set_signer(signer).await;
88
89 send_events(
90 &client,
91 Some(git_repo_path),
92 vec![issue_event],
93 user_ref.relays.write(),
94 repo_ref.relays.clone(),
95 true,
96 false,
97 )
98 .await?;
99
100 let event_bech32 = if let Some(relay) = repo_ref.relays.first() {
101 Nip19Event {
102 event_id,
103 relays: vec![relay.clone()],
104 author: None,
105 kind: None,
106 }
107 .to_bech32()?
108 } else {
109 event_id.to_bech32()?
110 };
111
112 println!("issue created: {event_id}");
113 let dim = console::Style::new().color256(247);
114 println!(
115 "{}",
116 dim.apply_to(format!(
117 "view in gitworkshop.dev: https://gitworkshop.dev/{}",
118 &event_bech32,
119 ))
120 );
121 Ok(())
122}
diff --git a/src/bin/ngit/sub_commands/issue_status.rs b/src/bin/ngit/sub_commands/issue_status.rs
new file mode 100644
index 0000000..3facee3
--- /dev/null
+++ b/src/bin/ngit/sub_commands/issue_status.rs
@@ -0,0 +1,178 @@
1use anyhow::{Context, Result, bail};
2use ngit::{
3 client::{Params, get_issues_from_cache, send_events, sign_event},
4 git_events::{get_status, status_kinds},
5};
6use nostr::{EventBuilder, Tag, TagStandard, nips::nip19::Nip19};
7use nostr_sdk::{EventId, FromBech32, Kind, nips::nip10::Marker};
8
9use crate::{
10 client::{
11 Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache,
12 },
13 git::{Repo, RepoActions},
14 login,
15 repo_ref::get_repo_coordinates_when_remote_unknown,
16};
17
18fn parse_event_id(id: &str) -> Result<EventId> {
19 if let Ok(nip19) = Nip19::from_bech32(id) {
20 match nip19 {
21 nostr::nips::nip19::Nip19::Event(e) => return Ok(e.event_id),
22 nostr::nips::nip19::Nip19::EventId(event_id) => return Ok(event_id),
23 _ => {}
24 }
25 }
26 if let Ok(event_id) = EventId::from_hex(id) {
27 return Ok(event_id);
28 }
29 bail!("invalid event-id or nevent: {id}")
30}
31
32#[allow(clippy::too_many_lines)]
33async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> Result<()> {
34 let event_id = parse_event_id(id)?;
35
36 let git_repo = Repo::discover().context("failed to find a git repository")?;
37 let git_repo_path = git_repo.get_path()?;
38
39 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
40 let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?;
41
42 if !offline {
43 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
44 }
45
46 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
47
48 let issues = get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?;
49
50 let issue = issues
51 .iter()
52 .find(|e| e.id == event_id)
53 .context(format!(
54 "issue with id {} not found in cache",
55 event_id.to_hex()
56 ))?
57 .clone();
58
59 // Login to get signer and user pubkey
60 let (signer, user_ref, _) =
61 login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?;
62
63 let user_pubkey = signer.get_public_key().await?;
64
65 // Only author or maintainer may change status
66 if issue.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) {
67 bail!("only the issue author or a repository maintainer can {action} an issue");
68 }
69
70 // Fetch existing statuses to check current state
71 let statuses = {
72 let mut s = get_events_from_local_cache(
73 git_repo_path,
74 vec![
75 nostr::Filter::default()
76 .kinds(status_kinds().clone())
77 .events(issues.iter().map(|e| e.id)),
78 nostr::Filter::default()
79 .custom_tags(
80 nostr::filter::SingleLetterTag::uppercase(nostr::filter::Alphabet::E),
81 issues.iter().map(|e| e.id),
82 )
83 .kinds(status_kinds().clone()),
84 ],
85 )
86 .await?;
87 s.sort_by_key(|e| e.created_at);
88 s.reverse();
89 s
90 };
91
92 let empty_proposals: Vec<nostr::Event> = vec![];
93 let current_status = get_status(&issue, &repo_ref, &statuses, &empty_proposals);
94
95 if current_status == new_kind {
96 let status_str = match new_kind {
97 Kind::GitStatusOpen => "open",
98 Kind::GitStatusClosed => "closed",
99 _ => "unknown",
100 };
101 println!("issue is already {status_str}");
102 return Ok(());
103 }
104
105 let alt_text = match new_kind {
106 Kind::GitStatusOpen => "issue reopened",
107 Kind::GitStatusClosed => "issue closed",
108 _ => "issue status updated",
109 };
110
111 let mut public_keys: std::collections::HashSet<nostr::PublicKey> =
112 repo_ref.maintainers.iter().copied().collect();
113 public_keys.insert(issue.pubkey);
114
115 let status_event = sign_event(
116 EventBuilder::new(new_kind, "").tags(
117 [
118 vec![
119 Tag::custom(
120 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
121 vec![alt_text.to_string()],
122 ),
123 Tag::from_standardized(TagStandard::Event {
124 event_id: issue.id,
125 relay_url: repo_ref.relays.first().cloned(),
126 marker: Some(Marker::Root),
127 public_key: None,
128 uppercase: false,
129 }),
130 ],
131 public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(),
132 repo_ref
133 .coordinates()
134 .iter()
135 .map(|c| {
136 Tag::from_standardized(TagStandard::Coordinate {
137 coordinate: c.coordinate.clone(),
138 relay_url: c.relays.first().cloned(),
139 uppercase: false,
140 })
141 })
142 .collect::<Vec<Tag>>(),
143 vec![Tag::from_standardized(nostr::TagStandard::Reference(
144 repo_ref.root_commit.to_string(),
145 ))],
146 ]
147 .concat(),
148 ),
149 &signer,
150 format!("{action} issue"),
151 )
152 .await?;
153
154 let mut client = client;
155 client.set_signer(signer).await;
156
157 send_events(
158 &client,
159 Some(git_repo_path),
160 vec![status_event],
161 user_ref.relays.write(),
162 repo_ref.relays.clone(),
163 true,
164 false,
165 )
166 .await?;
167
168 println!("issue {} {}d", &event_id.to_hex()[..8], action,);
169 Ok(())
170}
171
172pub async fn launch_close(id: &str, offline: bool) -> Result<()> {
173 launch_status(id, offline, Kind::GitStatusClosed, "close").await
174}
175
176pub async fn launch_reopen(id: &str, offline: bool) -> Result<()> {
177 launch_status(id, offline, Kind::GitStatusOpen, "reopen").await
178}
diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs
index d864391..60dc413 100644
--- a/src/bin/ngit/sub_commands/mod.rs
+++ b/src/bin/ngit/sub_commands/mod.rs
@@ -1,12 +1,17 @@
1pub mod apply; 1pub mod apply;
2pub mod checkout; 2pub mod checkout;
3pub mod comment;
3pub mod create; 4pub mod create;
4pub mod export_keys; 5pub mod export_keys;
5pub mod init; 6pub mod init;
7pub mod issue_create;
6pub mod issue_list; 8pub mod issue_list;
9pub mod issue_status;
7pub mod list; 10pub mod list;
8pub mod login; 11pub mod login;
9pub mod logout; 12pub mod logout;
13pub mod pr_merge;
14pub mod pr_status;
10pub mod repo; 15pub mod repo;
11pub mod send; 16pub mod send;
12pub mod sync; 17pub mod sync;
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}
diff --git a/src/bin/ngit/sub_commands/pr_status.rs b/src/bin/ngit/sub_commands/pr_status.rs
new file mode 100644
index 0000000..e84117d
--- /dev/null
+++ b/src/bin/ngit/sub_commands/pr_status.rs
@@ -0,0 +1,199 @@
1use anyhow::{Context, Result, bail};
2use ngit::{
3 client::{Params, get_proposals_and_revisions_from_cache, send_events, sign_event},
4 git_events::{get_status, status_kinds},
5};
6use nostr::{EventBuilder, Tag, TagStandard, ToBech32, nips::nip19::Nip19};
7use nostr_sdk::{EventId, FromBech32, Kind, nips::nip10::Marker};
8
9use crate::{
10 client::{
11 Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache,
12 },
13 git::{Repo, RepoActions},
14 login,
15 repo_ref::get_repo_coordinates_when_remote_unknown,
16};
17
18fn parse_event_id(id: &str) -> Result<EventId> {
19 if let Ok(nip19) = Nip19::from_bech32(id) {
20 match nip19 {
21 nostr::nips::nip19::Nip19::Event(e) => return Ok(e.event_id),
22 nostr::nips::nip19::Nip19::EventId(event_id) => return Ok(event_id),
23 _ => {}
24 }
25 }
26 if let Ok(event_id) = EventId::from_hex(id) {
27 return Ok(event_id);
28 }
29 bail!("invalid event-id or nevent: {id}")
30}
31
32#[allow(clippy::too_many_lines)]
33async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> Result<()> {
34 let event_id = parse_event_id(id)?;
35
36 let git_repo = Repo::discover().context("failed to find a git repository")?;
37 let git_repo_path = git_repo.get_path()?;
38
39 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
40 let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?;
41
42 if !offline {
43 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
44 }
45
46 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
47
48 let proposals_and_revisions =
49 get_proposals_and_revisions_from_cache(git_repo_path, repo_ref.coordinates()).await?;
50
51 let proposal = proposals_and_revisions
52 .iter()
53 .find(|e| e.id == event_id)
54 .context(format!(
55 "PR with id {} not found in cache",
56 event_id.to_hex()
57 ))?
58 .clone();
59
60 // Login to get signer and user pubkey
61 let (signer, user_ref, _) =
62 login::login_or_signup(&Some(&git_repo), &None, &None, Some(&client), true).await?;
63
64 let user_pubkey = signer.get_public_key().await?;
65
66 // Only author or maintainer may change status
67 if proposal.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) {
68 bail!("only the PR author or a repository maintainer can {action} a PR");
69 }
70
71 // Fetch existing statuses to check current state
72 let statuses = {
73 let mut s = get_events_from_local_cache(
74 git_repo_path,
75 vec![
76 nostr::Filter::default()
77 .kinds(status_kinds().clone())
78 .events(proposals_and_revisions.iter().map(|e| e.id)),
79 nostr::Filter::default()
80 .custom_tags(
81 nostr::filter::SingleLetterTag::uppercase(nostr::filter::Alphabet::E),
82 proposals_and_revisions.iter().map(|e| e.id),
83 )
84 .kinds(status_kinds().clone()),
85 ],
86 )
87 .await?;
88 s.sort_by_key(|e| e.created_at);
89 s.reverse();
90 s
91 };
92
93 let proposals_vec: Vec<nostr::Event> = proposals_and_revisions
94 .iter()
95 .filter(|e| !ngit::git_events::event_is_revision_root(e))
96 .cloned()
97 .collect();
98
99 let current_status = get_status(&proposal, &repo_ref, &statuses, &proposals_vec);
100
101 // Guard against no-op transitions
102 if current_status == new_kind {
103 let status_str = match new_kind {
104 Kind::GitStatusOpen => "open",
105 Kind::GitStatusClosed => "closed",
106 Kind::GitStatusDraft => "draft",
107 Kind::GitStatusApplied => "applied",
108 _ => "unknown",
109 };
110 println!("PR is already {status_str}");
111 return Ok(());
112 }
113
114 let alt_text = match new_kind {
115 Kind::GitStatusOpen => "PR reopened",
116 Kind::GitStatusClosed => "PR closed",
117 Kind::GitStatusDraft => "PR marked as draft",
118 Kind::GitStatusApplied => "PR applied/merged",
119 _ => "PR status updated",
120 };
121
122 // Build status event following the same pattern as push.rs
123 let mut public_keys: std::collections::HashSet<nostr::PublicKey> =
124 repo_ref.maintainers.iter().copied().collect();
125 public_keys.insert(proposal.pubkey);
126
127 let status_event = sign_event(
128 EventBuilder::new(new_kind, "").tags(
129 [
130 vec![
131 Tag::custom(
132 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
133 vec![alt_text.to_string()],
134 ),
135 Tag::from_standardized(TagStandard::Event {
136 event_id: proposal.id,
137 relay_url: repo_ref.relays.first().cloned(),
138 marker: Some(Marker::Root),
139 public_key: None,
140 uppercase: false,
141 }),
142 ],
143 public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(),
144 repo_ref
145 .coordinates()
146 .iter()
147 .map(|c| {
148 Tag::from_standardized(TagStandard::Coordinate {
149 coordinate: c.coordinate.clone(),
150 relay_url: c.relays.first().cloned(),
151 uppercase: false,
152 })
153 })
154 .collect::<Vec<Tag>>(),
155 vec![Tag::from_standardized(nostr::TagStandard::Reference(
156 repo_ref.root_commit.to_string(),
157 ))],
158 ]
159 .concat(),
160 ),
161 &signer,
162 format!("{action} PR"),
163 )
164 .await?;
165
166 let mut client = client;
167 client.set_signer(signer).await;
168
169 send_events(
170 &client,
171 Some(git_repo_path),
172 vec![status_event],
173 user_ref.relays.write(),
174 repo_ref.relays.clone(),
175 true,
176 false,
177 )
178 .await?;
179
180 println!(
181 "PR {} {}d: {}",
182 &event_id.to_hex()[..8],
183 action,
184 proposal.pubkey.to_bech32().unwrap_or_default()
185 );
186 Ok(())
187}
188
189pub async fn launch_close(id: &str, offline: bool) -> Result<()> {
190 launch_status(id, offline, Kind::GitStatusClosed, "close").await
191}
192
193pub async fn launch_reopen(id: &str, offline: bool) -> Result<()> {
194 launch_status(id, offline, Kind::GitStatusOpen, "reopen").await
195}
196
197pub async fn launch_ready(id: &str, offline: bool) -> Result<()> {
198 launch_status(id, offline, Kind::GitStatusOpen, "mark as ready").await
199}