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:
Diffstat (limited to 'src')
-rw-r--r--src/bin/ngit/cli.rs220
-rw-r--r--src/bin/ngit/main.rs91
-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
8 files changed, 1194 insertions, 52 deletions
diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs
index c2364e6..452491c 100644
--- a/src/bin/ngit/cli.rs
+++ b/src/bin/ngit/cli.rs
@@ -8,7 +8,7 @@ use crate::sub_commands;
8#[command( 8#[command(
9 author, 9 author,
10 version, 10 version,
11 help_template = "{name} {version}\nnostr plugin for git\n includes a remote helper so native git commands (clone, fetch, push) work with nostr:// URLs\n - clone a nostr repository, or add as a remote, by using the url format nostr://npub123/identifier\n - remote branches beginning with `pr/` are open PRs from contributors; `ngit list` can be used to view all PRs\n - to open a PR, push a branch with the prefix `pr/` or use `ngit send` for advanced options\n set title and description via push options:\n git push -o 'title=My PR' -o 'description=line1\\n\\nline2' -u origin pr/branch\n - publish a repository to nostr with `ngit init`\n\n{usage}\n{all-args}" 11 help_template = "{name} {version}\nnostr plugin for git\n includes a remote helper so native git commands (clone, fetch, push) work with nostr:// URLs\n - clone a nostr repository, or add as a remote, by using the url format nostr://npub123/identifier\n - remote branches beginning with `pr/` are open PRs from contributors; `ngit pr list` can be used to view all PRs\n - to open a PR, push a branch with the prefix `pr/` or use `ngit send` for advanced options\n set title and description via push options:\n git push -o 'title=My PR' -o 'description=line1\\n\\nline2' -u origin pr/branch\n - publish a repository to nostr with `ngit init`\n\n{usage}\n{all-args}"
12)] 12)]
13#[command(propagate_version = true)] 13#[command(propagate_version = true)]
14#[allow(clippy::struct_excessive_bools)] 14#[allow(clippy::struct_excessive_bools)]
@@ -119,6 +119,59 @@ pub enum Commands {
119 long_about = "submit PR with advanced options\n\nfor a simpler flow, push a branch with the `pr/` prefix using native git:\n git push -o 'title=My PR' -o 'description=details here' -u origin pr/my-branch" 119 long_about = "submit PR with advanced options\n\nfor a simpler flow, push a branch with the `pr/` prefix using native git:\n git push -o 'title=My PR' -o 'description=details here' -u origin pr/my-branch"
120 )] 120 )]
121 Send(sub_commands::send::SubCommandArgs), 121 Send(sub_commands::send::SubCommandArgs),
122 /// work with pull requests
123 #[command(
124 long_about = "work with pull requests\n\nPRs are created by pushing a branch with the `pr/` prefix:\n git push -u origin pr/my-branch\nor with advanced options via `ngit send`"
125 )]
126 Pr(PrSubCommandArgs),
127 /// work with issues
128 Issue(IssueSubCommandArgs),
129 /// update repo git servers to reflect nostr state (add, update or delete
130 /// remote refs)
131 Sync(sub_commands::sync::SubCommandArgs),
132 /// create account, login, logout or export keys
133 Account(AccountSubCommandArgs),
134}
135
136#[derive(Subcommand)]
137pub enum AccountCommands {
138 /// login with nsec or nostr connect
139 Login(sub_commands::login::SubCommandArgs),
140 /// remove nostr account details stored in git config
141 Logout,
142 /// export nostr keys to login to other nostr clients
143 ExportKeys,
144 /// create a new nostr account
145 Create(sub_commands::create::SubCommandArgs),
146}
147
148#[derive(clap::Parser)]
149pub struct AccountSubCommandArgs {
150 #[command(subcommand)]
151 pub account_command: AccountCommands,
152}
153
154#[derive(clap::Parser)]
155pub struct RepoSubCommandArgs {
156 #[command(subcommand)]
157 pub repo_command: Option<RepoCommands>,
158 /// Use local cache only, skip network fetch
159 #[arg(long)]
160 pub offline: bool,
161}
162
163// ---------------------------------------------------------------------------
164// PR subcommand group
165// ---------------------------------------------------------------------------
166
167#[derive(clap::Parser)]
168pub struct PrSubCommandArgs {
169 #[command(subcommand)]
170 pub pr_command: PrCommands,
171}
172
173#[derive(Subcommand)]
174pub enum PrCommands {
122 /// list PRs and view details 175 /// list PRs and view details
123 List { 176 List {
124 /// Filter by status (comma-separated: open,draft,closed,applied) 177 /// Filter by status (comma-separated: open,draft,closed,applied)
@@ -134,11 +187,21 @@ pub enum Commands {
134 #[arg(long)] 187 #[arg(long)]
135 offline: bool, 188 offline: bool,
136 }, 189 },
137 /// list issues 190 /// view a PR and its comments
138 Issue(IssueSubCommandArgs), 191 View {
192 /// Proposal event-id (hex) or nevent (bech32)
193 #[arg(value_name = "ID|nevent")]
194 id: String,
195 /// Output as JSON
196 #[arg(long)]
197 json: bool,
198 /// Use local cache only, skip network fetch
199 #[arg(long)]
200 offline: bool,
201 },
139 /// checkout a proposal branch by event-id or nevent 202 /// checkout a proposal branch by event-id or nevent
140 #[command( 203 #[command(
141 long_about = "checkout a proposal branch by event-id or nevent\n\nuse `ngit list` to find proposal IDs" 204 long_about = "checkout a proposal branch by event-id or nevent\n\nuse `ngit pr list` to find proposal IDs"
142 )] 205 )]
143 Checkout { 206 Checkout {
144 /// Proposal event-id (hex) or nevent (bech32) 207 /// Proposal event-id (hex) or nevent (bech32)
@@ -150,7 +213,7 @@ pub enum Commands {
150 }, 213 },
151 /// apply proposal patches to current branch 214 /// apply proposal patches to current branch
152 #[command( 215 #[command(
153 long_about = "apply proposal patches to current branch\n\nuse `ngit list` to find proposal IDs" 216 long_about = "apply proposal patches to current branch\n\nuse `ngit pr list` to find proposal IDs"
154 )] 217 )]
155 Apply { 218 Apply {
156 /// Proposal event-id or nevent 219 /// Proposal event-id or nevent
@@ -163,39 +226,70 @@ pub enum Commands {
163 #[arg(long)] 226 #[arg(long)]
164 offline: bool, 227 offline: bool,
165 }, 228 },
166 /// update repo git servers to reflect nostr state (add, update or delete 229 /// submit PR with advanced options (alias for `ngit send`)
167 /// remote refs) 230 #[command(
168 Sync(sub_commands::sync::SubCommandArgs), 231 long_about = "submit PR with advanced options\n\nfor a simpler flow, push a branch with the `pr/` prefix using native git:\n git push -o 'title=My PR' -o 'description=details here' -u origin pr/my-branch"
169 /// create account, login, logout or export keys 232 )]
170 Account(AccountSubCommandArgs), 233 Send(sub_commands::send::SubCommandArgs),
171} 234 /// close a PR (author or maintainer only)
172 235 Close {
173#[derive(Subcommand)] 236 /// Proposal event-id (hex) or nevent (bech32)
174pub enum AccountCommands { 237 #[arg(value_name = "ID|nevent")]
175 /// login with nsec or nostr connect 238 id: String,
176 Login(sub_commands::login::SubCommandArgs), 239 /// Use local cache only, skip network fetch
177 /// remove nostr account details stored in git config 240 #[arg(long)]
178 Logout, 241 offline: bool,
179 /// export nostr keys to login to other nostr clients 242 },
180 ExportKeys, 243 /// reopen a closed PR (author or maintainer only)
181 /// create a new nostr account 244 Reopen {
182 Create(sub_commands::create::SubCommandArgs), 245 /// Proposal event-id (hex) or nevent (bech32)
183} 246 #[arg(value_name = "ID|nevent")]
184 247 id: String,
185#[derive(clap::Parser)] 248 /// Use local cache only, skip network fetch
186pub struct AccountSubCommandArgs { 249 #[arg(long)]
187 #[command(subcommand)] 250 offline: bool,
188 pub account_command: AccountCommands, 251 },
252 /// mark a draft PR as ready for review (author or maintainer only)
253 Ready {
254 /// Proposal event-id (hex) or nevent (bech32)
255 #[arg(value_name = "ID|nevent")]
256 id: String,
257 /// Use local cache only, skip network fetch
258 #[arg(long)]
259 offline: bool,
260 },
261 /// add a comment to a PR
262 Comment {
263 /// Proposal event-id (hex) or nevent (bech32)
264 #[arg(value_name = "ID|nevent")]
265 id: String,
266 /// Comment body
267 #[arg(long)]
268 body: String,
269 /// Use local cache only, skip network fetch
270 #[arg(long)]
271 offline: bool,
272 },
273 /// merge a PR into the current branch (maintainer only)
274 #[command(
275 long_about = "merge a PR into the current branch (maintainer only)\n\nperforms a git merge of the PR branch; push afterwards to update the nostr state"
276 )]
277 Merge {
278 /// Proposal event-id (hex) or nevent (bech32)
279 #[arg(value_name = "ID|nevent")]
280 id: String,
281 /// Use squash merge
282 #[arg(long)]
283 squash: bool,
284 /// Use local cache only, skip network fetch
285 #[arg(long)]
286 offline: bool,
287 },
189} 288}
190 289
191#[derive(clap::Parser)] 290// ---------------------------------------------------------------------------
192pub struct RepoSubCommandArgs { 291// Issue subcommand group
193 #[command(subcommand)] 292// ---------------------------------------------------------------------------
194 pub repo_command: Option<RepoCommands>,
195 /// Use local cache only, skip network fetch
196 #[arg(long)]
197 pub offline: bool,
198}
199 293
200#[derive(clap::Parser)] 294#[derive(clap::Parser)]
201pub struct IssueSubCommandArgs { 295pub struct IssueSubCommandArgs {
@@ -223,6 +317,60 @@ pub enum IssueCommands {
223 #[arg(long)] 317 #[arg(long)]
224 offline: bool, 318 offline: bool,
225 }, 319 },
320 /// view an issue and its comments
321 View {
322 /// Issue event-id (hex) or nevent (bech32)
323 #[arg(value_name = "ID|nevent")]
324 id: String,
325 /// Output as JSON
326 #[arg(long)]
327 json: bool,
328 /// Use local cache only, skip network fetch
329 #[arg(long)]
330 offline: bool,
331 },
332 /// create a new issue
333 Create {
334 /// Issue title
335 #[arg(long)]
336 title: Option<String>,
337 /// Issue body / description
338 #[arg(long)]
339 body: Option<String>,
340 /// Hashtag labels (repeatable: --label bug --label help-wanted)
341 #[arg(long = "label", value_name = "LABEL")]
342 labels: Vec<String>,
343 },
344 /// close an issue (author or maintainer only)
345 Close {
346 /// Issue event-id (hex) or nevent (bech32)
347 #[arg(value_name = "ID|nevent")]
348 id: String,
349 /// Use local cache only, skip network fetch
350 #[arg(long)]
351 offline: bool,
352 },
353 /// reopen a closed issue (author or maintainer only)
354 Reopen {
355 /// Issue event-id (hex) or nevent (bech32)
356 #[arg(value_name = "ID|nevent")]
357 id: String,
358 /// Use local cache only, skip network fetch
359 #[arg(long)]
360 offline: bool,
361 },
362 /// add a comment to an issue
363 Comment {
364 /// Issue event-id (hex) or nevent (bech32)
365 #[arg(value_name = "ID|nevent")]
366 id: String,
367 /// Comment body
368 #[arg(long)]
369 body: String,
370 /// Use local cache only, skip network fetch
371 #[arg(long)]
372 offline: bool,
373 },
226} 374}
227 375
228#[derive(Subcommand)] 376#[derive(Subcommand)]
diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs
index cb0cc52..2982b61 100644
--- a/src/bin/ngit/main.rs
+++ b/src/bin/ngit/main.rs
@@ -3,7 +3,7 @@
3#![cfg_attr(not(test), warn(clippy::expect_used))] 3#![cfg_attr(not(test), warn(clippy::expect_used))]
4 4
5use clap::Parser; 5use clap::Parser;
6use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands, IssueCommands}; 6use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands, IssueCommands, PrCommands};
7 7
8mod cli; 8mod cli;
9use ngit::{ 9use ngit::{
@@ -16,6 +16,7 @@ use ngit::{
16mod sub_commands; 16mod sub_commands;
17 17
18#[tokio::main] 18#[tokio::main]
19#[allow(clippy::too_many_lines)]
19async fn main() { 20async fn main() {
20 let cli = Cli::parse(); 21 let cli = Cli::parse();
21 22
@@ -52,12 +53,52 @@ async fn main() {
52 Commands::Repo(args) => { 53 Commands::Repo(args) => {
53 sub_commands::repo::launch(&cli, args.repo_command.as_ref(), args.offline).await 54 sub_commands::repo::launch(&cli, args.repo_command.as_ref(), args.offline).await
54 } 55 }
55 Commands::List { 56 Commands::Send(args) => sub_commands::send::launch(&cli, args, false).await,
56 status, 57 Commands::Pr(args) => match &args.pr_command {
57 json, 58 PrCommands::List {
58 id, 59 status,
59 offline, 60 json,
60 } => sub_commands::list::launch(status.clone(), *json, id.clone(), *offline).await, 61 id,
62 offline,
63 } => sub_commands::list::launch(status.clone(), *json, id.clone(), *offline).await,
64 PrCommands::View { id, json, offline } => {
65 sub_commands::list::launch(
66 "open,draft,closed,applied".to_string(),
67 *json,
68 Some(id.clone()),
69 *offline,
70 )
71 .await
72 }
73 PrCommands::Checkout { id, offline } => {
74 sub_commands::checkout::launch(id, *offline).await
75 }
76 PrCommands::Apply {
77 id,
78 stdout,
79 offline,
80 } => sub_commands::apply::launch(id, *stdout, *offline).await,
81 PrCommands::Send(sub_args) => {
82 sub_commands::send::launch(&cli, sub_args, false).await
83 }
84 PrCommands::Close { id, offline } => {
85 sub_commands::pr_status::launch_close(id, *offline).await
86 }
87 PrCommands::Reopen { id, offline } => {
88 sub_commands::pr_status::launch_reopen(id, *offline).await
89 }
90 PrCommands::Ready { id, offline } => {
91 sub_commands::pr_status::launch_ready(id, *offline).await
92 }
93 PrCommands::Comment { id, body, offline } => {
94 sub_commands::comment::launch_pr_comment(id, body, *offline).await
95 }
96 PrCommands::Merge {
97 id,
98 squash,
99 offline,
100 } => sub_commands::pr_merge::launch(id, *squash, *offline).await,
101 },
61 Commands::Issue(args) => match &args.issue_command { 102 Commands::Issue(args) => match &args.issue_command {
62 IssueCommands::List { 103 IssueCommands::List {
63 status, 104 status,
@@ -75,17 +116,35 @@ async fn main() {
75 ) 116 )
76 .await 117 .await
77 } 118 }
119 IssueCommands::View { id, json, offline } => {
120 sub_commands::issue_list::launch(
121 "open,draft,closed,applied".to_string(),
122 None,
123 *json,
124 Some(id.clone()),
125 *offline,
126 )
127 .await
128 }
129 IssueCommands::Create {
130 title,
131 body,
132 labels,
133 } => {
134 sub_commands::issue_create::launch(title.clone(), body.clone(), labels.clone())
135 .await
136 }
137 IssueCommands::Close { id, offline } => {
138 sub_commands::issue_status::launch_close(id, *offline).await
139 }
140 IssueCommands::Reopen { id, offline } => {
141 sub_commands::issue_status::launch_reopen(id, *offline).await
142 }
143 IssueCommands::Comment { id, body, offline } => {
144 sub_commands::comment::launch_issue_comment(id, body, *offline).await
145 }
78 }, 146 },
79 Commands::Send(args) => sub_commands::send::launch(&cli, args, false).await,
80 Commands::Sync(args) => sub_commands::sync::launch(args).await, 147 Commands::Sync(args) => sub_commands::sync::launch(args).await,
81 Commands::Checkout { id, offline } => {
82 sub_commands::checkout::launch(id, *offline).await
83 }
84 Commands::Apply {
85 id,
86 stdout,
87 offline,
88 } => sub_commands::apply::launch(id, *stdout, *offline).await,
89 } 148 }
90 } else { 149 } else {
91 // Show help when no command is provided 150 // Show help when no command is provided
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}