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.rs30
-rw-r--r--src/bin/ngit/main.rs20
-rw-r--r--src/bin/ngit/sub_commands/issue_list.rs264
-rw-r--r--src/bin/ngit/sub_commands/mod.rs1
-rw-r--r--src/lib/client.rs24
5 files changed, 338 insertions, 1 deletions
diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs
index 68b18a7..c2364e6 100644
--- a/src/bin/ngit/cli.rs
+++ b/src/bin/ngit/cli.rs
@@ -134,6 +134,8 @@ pub enum Commands {
134 #[arg(long)] 134 #[arg(long)]
135 offline: bool, 135 offline: bool,
136 }, 136 },
137 /// list issues
138 Issue(IssueSubCommandArgs),
137 /// checkout a proposal branch by event-id or nevent 139 /// checkout a proposal branch by event-id or nevent
138 #[command( 140 #[command(
139 long_about = "checkout a proposal branch by event-id or nevent\n\nuse `ngit list` to find proposal IDs" 141 long_about = "checkout a proposal branch by event-id or nevent\n\nuse `ngit list` to find proposal IDs"
@@ -195,6 +197,34 @@ pub struct RepoSubCommandArgs {
195 pub offline: bool, 197 pub offline: bool,
196} 198}
197 199
200#[derive(clap::Parser)]
201pub struct IssueSubCommandArgs {
202 #[command(subcommand)]
203 pub issue_command: IssueCommands,
204}
205
206#[derive(Subcommand)]
207pub enum IssueCommands {
208 /// list issues and their statuses
209 List {
210 /// Filter by status (comma-separated: open,draft,closed,applied)
211 #[arg(long, default_value = "open")]
212 status: String,
213 /// Filter by hashtag/label (comma-separated)
214 #[arg(long)]
215 hashtag: Option<String>,
216 /// Output as JSON
217 #[arg(long)]
218 json: bool,
219 /// Show details for a specific issue (event-id or nevent)
220 #[arg(value_name = "ID|nevent")]
221 id: Option<String>,
222 /// Use local cache only, skip network fetch
223 #[arg(long)]
224 offline: bool,
225 },
226}
227
198#[derive(Subcommand)] 228#[derive(Subcommand)]
199pub enum RepoCommands { 229pub enum RepoCommands {
200 /// publish a repository to nostr (alias for `ngit init`) 230 /// publish a repository to nostr (alias for `ngit init`)
diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs
index 5a7654a..cb0cc52 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}; 6use cli::{AccountCommands, CUSTOMISE_TEMPLATE, Cli, Commands, IssueCommands};
7 7
8mod cli; 8mod cli;
9use ngit::{ 9use ngit::{
@@ -58,6 +58,24 @@ async fn main() {
58 id, 58 id,
59 offline, 59 offline,
60 } => sub_commands::list::launch(status.clone(), *json, id.clone(), *offline).await, 60 } => sub_commands::list::launch(status.clone(), *json, id.clone(), *offline).await,
61 Commands::Issue(args) => match &args.issue_command {
62 IssueCommands::List {
63 status,
64 hashtag,
65 json,
66 id,
67 offline,
68 } => {
69 sub_commands::issue_list::launch(
70 status.clone(),
71 hashtag.clone(),
72 *json,
73 id.clone(),
74 *offline,
75 )
76 .await
77 }
78 },
61 Commands::Send(args) => sub_commands::send::launch(&cli, args, false).await, 79 Commands::Send(args) => sub_commands::send::launch(&cli, args, false).await,
62 Commands::Sync(args) => sub_commands::sync::launch(args).await, 80 Commands::Sync(args) => sub_commands::sync::launch(args).await,
63 Commands::Checkout { id, offline } => { 81 Commands::Checkout { id, offline } => {
diff --git a/src/bin/ngit/sub_commands/issue_list.rs b/src/bin/ngit/sub_commands/issue_list.rs
new file mode 100644
index 0000000..6b31db2
--- /dev/null
+++ b/src/bin/ngit/sub_commands/issue_list.rs
@@ -0,0 +1,264 @@
1use std::collections::HashSet;
2
3use anyhow::{Context, Result, bail};
4use ngit::{
5 client::{Params, get_issues_from_cache},
6 git_events::{get_status, status_kinds, tag_value},
7};
8use nostr::{
9 FromBech32,
10 filter::{Alphabet, SingleLetterTag},
11 nips::nip19::Nip19,
12};
13use nostr_sdk::Kind;
14
15use crate::{
16 client::{Client, Connect, fetching_with_report, get_events_from_local_cache, get_repo_ref_from_cache},
17 git::{Repo, RepoActions},
18 repo_ref::get_repo_coordinates_when_remote_unknown,
19};
20
21fn get_issue_title(event: &nostr::Event) -> String {
22 tag_value(event, "subject")
23 .ok()
24 .filter(|s| !s.is_empty())
25 .unwrap_or_else(|| {
26 let first_line = event.content.lines().next().unwrap_or("").trim().to_string();
27 if first_line.is_empty() {
28 event.id.to_string()
29 } else {
30 first_line
31 }
32 })
33}
34
35fn get_issue_hashtags(event: &nostr::Event) -> Vec<String> {
36 event
37 .tags
38 .iter()
39 .filter(|t| {
40 let s = t.as_slice();
41 s.len() >= 2 && s[0].eq("t")
42 })
43 .map(|t| t.as_slice()[1].clone())
44 .collect()
45}
46
47fn status_kind_to_str(kind: Kind) -> &'static str {
48 match kind {
49 Kind::GitStatusOpen => "open",
50 Kind::GitStatusDraft => "draft",
51 Kind::GitStatusClosed => "closed",
52 Kind::GitStatusApplied => "applied",
53 _ => "unknown",
54 }
55}
56
57#[allow(clippy::too_many_lines)]
58pub async fn launch(
59 status: String,
60 hashtag: Option<String>,
61 json: bool,
62 id: Option<String>,
63 offline: bool,
64) -> Result<()> {
65 let git_repo = Repo::discover().context("failed to find a git repository")?;
66 let git_repo_path = git_repo.get_path()?;
67
68 let client = Client::new(Params::with_git_config_relay_defaults(&Some(&git_repo)));
69
70 let repo_coordinates = get_repo_coordinates_when_remote_unknown(&git_repo, &client).await?;
71
72 if !offline {
73 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
74 }
75
76 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
77
78 let issues: Vec<nostr::Event> =
79 get_issues_from_cache(git_repo_path, repo_ref.coordinates()).await?;
80
81 if issues.is_empty() {
82 println!("no issues found");
83 return Ok(());
84 }
85
86 let statuses: Vec<nostr::Event> = {
87 let mut statuses = get_events_from_local_cache(
88 git_repo_path,
89 vec![
90 nostr::Filter::default()
91 .kinds(status_kinds().clone())
92 .events(issues.iter().map(|e| e.id)),
93 nostr::Filter::default()
94 .custom_tags(
95 SingleLetterTag::uppercase(Alphabet::E),
96 issues.iter().map(|e| e.id),
97 )
98 .kinds(status_kinds().clone()),
99 ],
100 )
101 .await?;
102 statuses.sort_by_key(|e| e.created_at);
103 statuses.reverse();
104 statuses
105 };
106
107 let status_filter: HashSet<&str> = status.split(',').map(str::trim).collect();
108
109 let hashtag_filter: Option<HashSet<String>> = hashtag.map(|h| {
110 h.split(',')
111 .map(|s| s.trim().to_lowercase())
112 .collect::<HashSet<String>>()
113 });
114
115 // Use an empty vec as the "all_pr_roots" argument — issues don't have PR
116 // revisions, so we pass an empty slice.
117 let empty_proposals: Vec<nostr::Event> = vec![];
118
119 let filtered: Vec<(&nostr::Event, Kind, Vec<String>)> = issues
120 .iter()
121 .filter_map(|issue| {
122 let status_kind = get_status(issue, &repo_ref, &statuses, &empty_proposals);
123 let status_str = status_kind_to_str(status_kind);
124 if !status_filter.contains(status_str) && !status_filter.contains("unknown") {
125 return None;
126 }
127 let tags = get_issue_hashtags(issue);
128 if let Some(ref hf) = hashtag_filter {
129 let issue_tags_lower: HashSet<String> =
130 tags.iter().map(|t| t.to_lowercase()).collect();
131 if !hf.iter().any(|h| issue_tags_lower.contains(h)) {
132 return None;
133 }
134 }
135 Some((issue, status_kind, tags))
136 })
137 .collect();
138
139 if filtered.is_empty() {
140 println!("no issues found matching the given filters");
141 return Ok(());
142 }
143
144 if let Some(ref event_id_or_nevent) = id {
145 return show_issue_details(&filtered, event_id_or_nevent, json);
146 }
147
148 if json {
149 output_json(&filtered)?;
150 } else {
151 output_table(&filtered, &status, hashtag_filter.as_ref());
152 }
153
154 Ok(())
155}
156
157fn show_issue_details(
158 issues: &[(&nostr::Event, Kind, Vec<String>)],
159 event_id_or_nevent: &str,
160 json: bool,
161) -> Result<()> {
162 let target_id = if event_id_or_nevent.starts_with("nevent") {
163 let nip19 = Nip19::from_bech32(event_id_or_nevent).context("failed to parse nevent")?;
164 match nip19 {
165 Nip19::EventId(id) => id,
166 Nip19::Event(event) => event.event_id,
167 _ => bail!("invalid nevent format"),
168 }
169 } else {
170 nostr::EventId::from_hex(event_id_or_nevent).context("failed to parse event id")?
171 };
172
173 let (issue, status_kind, tags) = issues
174 .iter()
175 .find(|(e, _, _)| e.id == target_id)
176 .context("issue not found")?;
177
178 let title = get_issue_title(issue);
179 let status = status_kind_to_str(*status_kind);
180
181 if json {
182 use nostr::ToBech32;
183 let json_output = serde_json::json!({
184 "id": issue.id.to_string(),
185 "status": status,
186 "title": title,
187 "author": issue.pubkey.to_bech32().unwrap_or_default(),
188 "hashtags": tags,
189 "description": issue.content,
190 });
191 println!("{}", serde_json::to_string_pretty(&json_output)?);
192 return Ok(());
193 }
194
195 println!("Title: {title}");
196 use nostr::ToBech32;
197 println!("Author: {}", issue.pubkey.to_bech32().unwrap_or_default());
198 println!("Status: {status}");
199 if !tags.is_empty() {
200 let tags_str = tags.iter().map(|t| format!("#{t}")).collect::<Vec<_>>().join(" ");
201 println!("Tags: {tags_str}");
202 }
203
204 if !issue.content.is_empty() {
205 println!();
206 for line in issue.content.lines() {
207 println!(" {line}");
208 }
209 }
210
211 Ok(())
212}
213
214fn output_table(
215 issues: &[(&nostr::Event, Kind, Vec<String>)],
216 status_filter: &str,
217 hashtag_filter: Option<&HashSet<String>>,
218) {
219 println!("{:<66} {:<8} TITLE HASHTAGS", "ID", "STATUS");
220 for (issue, status_kind, tags) in issues {
221 let id = issue.id.to_string();
222 let status = status_kind_to_str(*status_kind);
223 let title = get_issue_title(issue);
224 let tags_str = if tags.is_empty() {
225 String::new()
226 } else {
227 tags.iter()
228 .map(|t| format!("#{t}"))
229 .collect::<Vec<_>>()
230 .join(" ")
231 };
232 if tags_str.is_empty() {
233 println!("{id:<66} {status:<8} {title}");
234 } else {
235 println!("{id:<66} {status:<8} {title} {tags_str}");
236 }
237 }
238
239 println!();
240 print!("--status {status_filter}");
241 if let Some(hf) = hashtag_filter {
242 let tags: Vec<&String> = hf.iter().collect();
243 print!(" --hashtag {}", tags.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(","));
244 }
245 println!();
246}
247
248fn output_json(issues: &[(&nostr::Event, Kind, Vec<String>)]) -> Result<()> {
249 use nostr::ToBech32;
250 let json_output: Vec<serde_json::Value> = issues
251 .iter()
252 .map(|(issue, status_kind, tags)| {
253 serde_json::json!({
254 "id": issue.id.to_string(),
255 "status": status_kind_to_str(*status_kind),
256 "title": get_issue_title(issue),
257 "author": issue.pubkey.to_bech32().unwrap_or_default(),
258 "hashtags": tags,
259 })
260 })
261 .collect();
262 println!("{}", serde_json::to_string_pretty(&json_output)?);
263 Ok(())
264}
diff --git a/src/bin/ngit/sub_commands/mod.rs b/src/bin/ngit/sub_commands/mod.rs
index d132240..d864391 100644
--- a/src/bin/ngit/sub_commands/mod.rs
+++ b/src/bin/ngit/sub_commands/mod.rs
@@ -3,6 +3,7 @@ pub mod checkout;
3pub mod create; 3pub mod create;
4pub mod export_keys; 4pub mod export_keys;
5pub mod init; 5pub mod init;
6pub mod issue_list;
6pub mod list; 7pub mod list;
7pub mod login; 8pub mod login;
8pub mod logout; 9pub mod logout;
diff --git a/src/lib/client.rs b/src/lib/client.rs
index 62db8d2..1f46e3c 100644
--- a/src/lib/client.rs
+++ b/src/lib/client.rs
@@ -2505,6 +2505,30 @@ pub async fn fetching_quietly(
2505 Ok((report, had_errors)) 2505 Ok((report, had_errors))
2506} 2506}
2507 2507
2508pub async fn get_issues_from_cache(
2509 git_repo_path: &Path,
2510 repo_coordinates: HashSet<Nip19Coordinate>,
2511) -> Result<Vec<nostr::Event>> {
2512 let mut issues = get_events_from_local_cache(
2513 git_repo_path,
2514 vec![
2515 nostr::Filter::default()
2516 .kinds([nostr::Kind::GitIssue])
2517 .custom_tags(
2518 nostr::SingleLetterTag::lowercase(nostr_sdk::Alphabet::A),
2519 repo_coordinates
2520 .iter()
2521 .map(|c| c.coordinate.to_string())
2522 .collect::<Vec<String>>(),
2523 ),
2524 ],
2525 )
2526 .await?;
2527 issues.sort_by_key(|e| e.created_at);
2528 issues.reverse();
2529 Ok(issues)
2530}
2531
2508pub async fn get_proposals_and_revisions_from_cache( 2532pub async fn get_proposals_and_revisions_from_cache(
2509 git_repo_path: &Path, 2533 git_repo_path: &Path,
2510 repo_coordinates: HashSet<Nip19Coordinate>, 2534 repo_coordinates: HashSet<Nip19Coordinate>,