upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md11
-rw-r--r--skills/ngit/SKILL.md4
-rw-r--r--src/bin/ngit/cli.rs29
-rw-r--r--src/bin/ngit/main.rs11
-rw-r--r--src/bin/ngit/sub_commands/issue_status.rs30
-rw-r--r--src/bin/ngit/sub_commands/pr_status.rs29
6 files changed, 89 insertions, 25 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3e9c1ca..b2d5617 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,18 +13,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
13- `ngit issue label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing issue (author or maintainer only); publishes a kind-1985 event; duplicate labels already present via `t` tags or prior kind-1985 events are silently skipped; the new event is saved to the local cache before broadcasting so subsequent reads reflect the change immediately 13- `ngit issue label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing issue (author or maintainer only); publishes a kind-1985 event; duplicate labels already present via `t` tags or prior kind-1985 events are silently skipped; the new event is saved to the local cache before broadcasting so subsequent reads reflect the change immediately
14- `ngit pr label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing PR (author or maintainer only); publishes a kind-1985 event; duplicate labels already present via `t` tags or prior kind-1985 events are silently skipped; the new event is saved to the local cache before broadcasting so subsequent reads reflect the change immediately 14- `ngit pr label <id> --label <L> [--label <L>...]` — apply one or more NIP-32 hashtag labels to an existing PR (author or maintainer only); publishes a kind-1985 event; duplicate labels already present via `t` tags or prior kind-1985 events are silently skipped; the new event is saved to the local cache before broadcasting so subsequent reads reflect the change immediately
15- `ngit account whoami` — show the currently logged-in account(s) 15- `ngit account whoami` — show the currently logged-in account(s)
16- `ngit pr` subcommand group: `list`, `view`, `checkout`, `apply`, `send`, `close`, `reopen`, `ready`, `comment`, `merge`; replaces the former top-level `ngit list`, `ngit checkout`, and `ngit apply` commands (hard-migrated); `ngit send` remains at the top level unchanged 16- `ngit pr` subcommand group: `list`, `view`, `checkout`, `apply`, `send`, `close`, `reopen`, `ready`, `draft`, `comment`, `merge`; replaces the former top-level `ngit list`, `ngit checkout`, and `ngit apply` commands (hard-migrated); `ngit send` remains at the top level unchanged
17- `ngit pr view <id>` — view a PR with its full details and all comments (author, timestamp, body) in chronological order 17- `ngit pr view <id>` — view a PR with its full details and all comments (author, timestamp, body) in chronological order
18- `ngit pr close <id>` / `ngit pr reopen <id>` — change PR status (author or maintainer only) 18- `ngit pr close <id>` / `ngit pr reopen <id>` — change PR status (author or maintainer only)
19- `ngit pr ready <id>` — mark a draft PR as ready for review (author or maintainer only) 19- `ngit pr ready <id>` — mark a draft PR as ready for review (author or maintainer only)
20- `ngit pr draft <id>` — convert a PR back to draft (author or maintainer only)
20- `ngit pr comment <id> --body <text>` — post a NIP-22 comment on a PR 21- `ngit pr comment <id> --body <text>` — post a NIP-22 comment on a PR
21- `ngit pr merge <id> [--squash]` — merge a PR branch and publish a `GitStatusApplied` event (maintainer only); prints a reminder to push afterwards 22- `ngit pr merge <id> [--squash]` — merge a PR branch and publish a `GitStatusApplied` event (maintainer only); prints a reminder to push afterwards
22- `ngit issue` subcommand group expanded: `list`, `view`, `create`, `close`, `reopen`, `comment` 23- `ngit issue` subcommand group: `list`, `view`, `create`, `close`, `resolved`, `reopen`, `comment`, `label`
23- `ngit issue view <id>` — view an issue with its full details and all comments (author, timestamp, body) in chronological order 24- `ngit issue view <id>` — view an issue with its full details and all comments (author, timestamp, body) in chronological order
24- `ngit issue create --title <T> [--body <B>] [--label <L>...]` — publish a NIP-34 GitIssue event 25- `ngit issue create --title <T> [--body <B>] [--label <L>...]` — publish a NIP-34 GitIssue event
25- `ngit issue close <id>` / `ngit issue reopen <id>` — change issue status (author or maintainer only) 26- `ngit issue close <id> [--reason <text>]` — close an issue without resolving it; reason is stored in the event content (author or maintainer only)
27- `ngit issue resolved <id> [--reason <text>]` — mark an issue as resolved (kind-1631 `GitStatusApplied`); distinct from close, use when the issue has been fixed; reason stored in event content (author or maintainer only)
28- `ngit issue reopen <id>` — reopen a closed or resolved issue (author or maintainer only)
26- `ngit issue comment <id> --body <text>` — post a NIP-22 comment on an issue 29- `ngit issue comment <id> --body <text>` — post a NIP-22 comment on an issue
27- `ngit issue list` command: lists NIP-34 issues with their status; supports `--status` (comma-separated: open,draft,closed,applied; default: open), `--hashtag` (comma-separated label filter), `--json`, `--offline`, and an optional `<id>` positional argument to show full details of a specific issue; hashtags are shown at the end of each row 30- `ngit issue list` command: lists NIP-34 issues with their status; supports `--status` (comma-separated: open,draft,closed,applied; default: open), `--label` filter, `--json`, `--offline`, and an optional `<id>` positional argument to show full details of a specific issue
28- `nostr.repo-relay-only` git config key: when set to `true`, nostr events are sent only to the repository's own relays, skipping the user's personal write relays and default/blaster relays; set persistently via `git config nostr.repo-relay-only true` or in one step with `ngit init --repo-relay-only` 31- `nostr.repo-relay-only` git config key: when set to `true`, nostr events are sent only to the repository's own relays, skipping the user's personal write relays and default/blaster relays; set persistently via `git config nostr.repo-relay-only true` or in one step with `ngit init --repo-relay-only`
29 32
30## [2.2.3] - 2026-02-27 33## [2.2.3] - 2026-02-27
diff --git a/skills/ngit/SKILL.md b/skills/ngit/SKILL.md
index 1436452..b097cdf 100644
--- a/skills/ngit/SKILL.md
+++ b/skills/ngit/SKILL.md
@@ -122,6 +122,7 @@ git push origin main # push to nostr remote records the merge event
122ngit pr close <ID|nevent> 122ngit pr close <ID|nevent>
123ngit pr reopen <ID|nevent> 123ngit pr reopen <ID|nevent>
124ngit pr ready <ID|nevent> # mark draft as ready for review 124ngit pr ready <ID|nevent> # mark draft as ready for review
125ngit pr draft <ID|nevent> # convert back to draft
125ngit pr label <ID|nevent> --label bug --label enhancement 126ngit pr label <ID|nevent> --label bug --label enhancement
126``` 127```
127 128
@@ -137,7 +138,8 @@ ngit issue view <ID|nevent> --json
137ngit issue view <ID|nevent> --json --comments 138ngit issue view <ID|nevent> --json --comments
138ngit issue comment <ID|nevent> --body "Reproduced on v2.1" 139ngit issue comment <ID|nevent> --body "Reproduced on v2.1"
139ngit issue comment <ID|nevent> --body "Thanks!" --reply-to <comment-ID|nevent> 140ngit issue comment <ID|nevent> --body "Thanks!" --reply-to <comment-ID|nevent>
140ngit issue close <ID|nevent> 141ngit issue close <ID|nevent> --reason "wontfix" # closed without resolution
142ngit issue resolved <ID|nevent> --reason "fixed in abc123"
141ngit issue reopen <ID|nevent> 143ngit issue reopen <ID|nevent>
142ngit issue label <ID|nevent> --label bug --label enhancement 144ngit issue label <ID|nevent> --label bug --label enhancement
143``` 145```
diff --git a/src/bin/ngit/cli.rs b/src/bin/ngit/cli.rs
index b9b274e..37d85a2 100644
--- a/src/bin/ngit/cli.rs
+++ b/src/bin/ngit/cli.rs
@@ -270,6 +270,15 @@ pub enum PrCommands {
270 #[arg(long)] 270 #[arg(long)]
271 offline: bool, 271 offline: bool,
272 }, 272 },
273 /// convert a PR back to draft (author or maintainer only)
274 Draft {
275 /// Proposal event-id (hex) or nevent (bech32)
276 #[arg(value_name = "ID|nevent")]
277 id: String,
278 /// Use local cache only, skip network fetch
279 #[arg(long)]
280 offline: bool,
281 },
273 /// add a comment to a PR 282 /// add a comment to a PR
274 Comment { 283 Comment {
275 /// Proposal event-id (hex) or nevent (bech32) 284 /// Proposal event-id (hex) or nevent (bech32)
@@ -373,11 +382,29 @@ pub enum IssueCommands {
373 #[arg(long = "label", value_name = "LABEL")] 382 #[arg(long = "label", value_name = "LABEL")]
374 labels: Vec<String>, 383 labels: Vec<String>,
375 }, 384 },
376 /// close an issue (author or maintainer only) 385 /// close an issue without resolving it (author or maintainer only)
377 Close { 386 Close {
378 /// Issue event-id (hex) or nevent (bech32) 387 /// Issue event-id (hex) or nevent (bech32)
379 #[arg(value_name = "ID|nevent")] 388 #[arg(value_name = "ID|nevent")]
380 id: String, 389 id: String,
390 /// Optional reason (e.g. wontfix, duplicate, invalid)
391 #[arg(long)]
392 reason: Option<String>,
393 /// Use local cache only, skip network fetch
394 #[arg(long)]
395 offline: bool,
396 },
397 /// mark an issue as resolved (author or maintainer only)
398 #[command(
399 long_about = "mark an issue as resolved (author or maintainer only)\n\nuse this when the issue has been fixed or addressed, as distinct from closing without resolution"
400 )]
401 Resolved {
402 /// Issue event-id (hex) or nevent (bech32)
403 #[arg(value_name = "ID|nevent")]
404 id: String,
405 /// Optional reason or resolution summary
406 #[arg(long)]
407 reason: Option<String>,
381 /// Use local cache only, skip network fetch 408 /// Use local cache only, skip network fetch
382 #[arg(long)] 409 #[arg(long)]
383 offline: bool, 410 offline: bool,
diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs
index b0cf375..a0cb3e6 100644
--- a/src/bin/ngit/main.rs
+++ b/src/bin/ngit/main.rs
@@ -111,6 +111,9 @@ async fn main() {
111 PrCommands::Ready { id, offline } => { 111 PrCommands::Ready { id, offline } => {
112 sub_commands::pr_status::launch_ready(id, *offline).await 112 sub_commands::pr_status::launch_ready(id, *offline).await
113 } 113 }
114 PrCommands::Draft { id, offline } => {
115 sub_commands::pr_status::launch_draft(id, *offline).await
116 }
114 PrCommands::Comment { 117 PrCommands::Comment {
115 id, 118 id,
116 body, 119 body,
@@ -178,8 +181,12 @@ async fn main() {
178 sub_commands::issue_create::launch(title.clone(), body.clone(), labels.clone()) 181 sub_commands::issue_create::launch(title.clone(), body.clone(), labels.clone())
179 .await 182 .await
180 } 183 }
181 IssueCommands::Close { id, offline } => { 184 IssueCommands::Close { id, reason, offline } => {
182 sub_commands::issue_status::launch_close(id, *offline).await 185 sub_commands::issue_status::launch_close(id, *offline, reason.as_deref()).await
186 }
187 IssueCommands::Resolved { id, reason, offline } => {
188 sub_commands::issue_status::launch_resolved(id, *offline, reason.as_deref())
189 .await
183 } 190 }
184 IssueCommands::Reopen { id, offline } => { 191 IssueCommands::Reopen { id, offline } => {
185 sub_commands::issue_status::launch_reopen(id, *offline).await 192 sub_commands::issue_status::launch_reopen(id, *offline).await
diff --git a/src/bin/ngit/sub_commands/issue_status.rs b/src/bin/ngit/sub_commands/issue_status.rs
index 3facee3..840ab8e 100644
--- a/src/bin/ngit/sub_commands/issue_status.rs
+++ b/src/bin/ngit/sub_commands/issue_status.rs
@@ -30,7 +30,13 @@ fn parse_event_id(id: &str) -> Result<EventId> {
30} 30}
31 31
32#[allow(clippy::too_many_lines)] 32#[allow(clippy::too_many_lines)]
33async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> Result<()> { 33async fn launch_status(
34 id: &str,
35 offline: bool,
36 new_kind: Kind,
37 action: &str,
38 reason: Option<&str>,
39) -> Result<()> {
34 let event_id = parse_event_id(id)?; 40 let event_id = parse_event_id(id)?;
35 41
36 let git_repo = Repo::discover().context("failed to find a git repository")?; 42 let git_repo = Repo::discover().context("failed to find a git repository")?;
@@ -64,7 +70,7 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) ->
64 70
65 // Only author or maintainer may change status 71 // Only author or maintainer may change status
66 if issue.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { 72 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"); 73 bail!("only the issue author or a repository maintainer can change the status of an issue");
68 } 74 }
69 75
70 // Fetch existing statuses to check current state 76 // Fetch existing statuses to check current state
@@ -96,6 +102,7 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) ->
96 let status_str = match new_kind { 102 let status_str = match new_kind {
97 Kind::GitStatusOpen => "open", 103 Kind::GitStatusOpen => "open",
98 Kind::GitStatusClosed => "closed", 104 Kind::GitStatusClosed => "closed",
105 Kind::GitStatusApplied => "resolved",
99 _ => "unknown", 106 _ => "unknown",
100 }; 107 };
101 println!("issue is already {status_str}"); 108 println!("issue is already {status_str}");
@@ -105,6 +112,7 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) ->
105 let alt_text = match new_kind { 112 let alt_text = match new_kind {
106 Kind::GitStatusOpen => "issue reopened", 113 Kind::GitStatusOpen => "issue reopened",
107 Kind::GitStatusClosed => "issue closed", 114 Kind::GitStatusClosed => "issue closed",
115 Kind::GitStatusApplied => "issue resolved",
108 _ => "issue status updated", 116 _ => "issue status updated",
109 }; 117 };
110 118
@@ -112,8 +120,10 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) ->
112 repo_ref.maintainers.iter().copied().collect(); 120 repo_ref.maintainers.iter().copied().collect();
113 public_keys.insert(issue.pubkey); 121 public_keys.insert(issue.pubkey);
114 122
123 let content = reason.unwrap_or("").to_string();
124
115 let status_event = sign_event( 125 let status_event = sign_event(
116 EventBuilder::new(new_kind, "").tags( 126 EventBuilder::new(new_kind, content).tags(
117 [ 127 [
118 vec![ 128 vec![
119 Tag::custom( 129 Tag::custom(
@@ -147,7 +157,7 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) ->
147 .concat(), 157 .concat(),
148 ), 158 ),
149 &signer, 159 &signer,
150 format!("{action} issue"), 160 format!("issue {action}"),
151 ) 161 )
152 .await?; 162 .await?;
153 163
@@ -165,14 +175,18 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) ->
165 ) 175 )
166 .await?; 176 .await?;
167 177
168 println!("issue {} {}d", &event_id.to_hex()[..8], action,); 178 println!("issue {} {action}", &event_id.to_hex()[..8]);
169 Ok(()) 179 Ok(())
170} 180}
171 181
172pub async fn launch_close(id: &str, offline: bool) -> Result<()> { 182pub async fn launch_close(id: &str, offline: bool, reason: Option<&str>) -> Result<()> {
173 launch_status(id, offline, Kind::GitStatusClosed, "close").await 183 launch_status(id, offline, Kind::GitStatusClosed, "closed", reason).await
174} 184}
175 185
176pub async fn launch_reopen(id: &str, offline: bool) -> Result<()> { 186pub async fn launch_reopen(id: &str, offline: bool) -> Result<()> {
177 launch_status(id, offline, Kind::GitStatusOpen, "reopen").await 187 launch_status(id, offline, Kind::GitStatusOpen, "reopened", None).await
188}
189
190pub async fn launch_resolved(id: &str, offline: bool, reason: Option<&str>) -> Result<()> {
191 launch_status(id, offline, Kind::GitStatusApplied, "resolved", reason).await
178} 192}
diff --git a/src/bin/ngit/sub_commands/pr_status.rs b/src/bin/ngit/sub_commands/pr_status.rs
index e84117d..12aafb7 100644
--- a/src/bin/ngit/sub_commands/pr_status.rs
+++ b/src/bin/ngit/sub_commands/pr_status.rs
@@ -30,7 +30,13 @@ fn parse_event_id(id: &str) -> Result<EventId> {
30} 30}
31 31
32#[allow(clippy::too_many_lines)] 32#[allow(clippy::too_many_lines)]
33async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> Result<()> { 33async fn launch_status(
34 id: &str,
35 offline: bool,
36 new_kind: Kind,
37 action: &str,
38 reason: Option<&str>,
39) -> Result<()> {
34 let event_id = parse_event_id(id)?; 40 let event_id = parse_event_id(id)?;
35 41
36 let git_repo = Repo::discover().context("failed to find a git repository")?; 42 let git_repo = Repo::discover().context("failed to find a git repository")?;
@@ -65,7 +71,7 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) ->
65 71
66 // Only author or maintainer may change status 72 // Only author or maintainer may change status
67 if proposal.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { 73 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"); 74 bail!("only the PR author or a repository maintainer can change the status of a PR");
69 } 75 }
70 76
71 // Fetch existing statuses to check current state 77 // Fetch existing statuses to check current state
@@ -124,8 +130,10 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) ->
124 repo_ref.maintainers.iter().copied().collect(); 130 repo_ref.maintainers.iter().copied().collect();
125 public_keys.insert(proposal.pubkey); 131 public_keys.insert(proposal.pubkey);
126 132
133 let content = reason.unwrap_or("").to_string();
134
127 let status_event = sign_event( 135 let status_event = sign_event(
128 EventBuilder::new(new_kind, "").tags( 136 EventBuilder::new(new_kind, content).tags(
129 [ 137 [
130 vec![ 138 vec![
131 Tag::custom( 139 Tag::custom(
@@ -159,7 +167,7 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) ->
159 .concat(), 167 .concat(),
160 ), 168 ),
161 &signer, 169 &signer,
162 format!("{action} PR"), 170 format!("PR {action}"),
163 ) 171 )
164 .await?; 172 .await?;
165 173
@@ -178,22 +186,25 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) ->
178 .await?; 186 .await?;
179 187
180 println!( 188 println!(
181 "PR {} {}d: {}", 189 "PR {} {action}: {}",
182 &event_id.to_hex()[..8], 190 &event_id.to_hex()[..8],
183 action,
184 proposal.pubkey.to_bech32().unwrap_or_default() 191 proposal.pubkey.to_bech32().unwrap_or_default()
185 ); 192 );
186 Ok(()) 193 Ok(())
187} 194}
188 195
189pub async fn launch_close(id: &str, offline: bool) -> Result<()> { 196pub async fn launch_close(id: &str, offline: bool) -> Result<()> {
190 launch_status(id, offline, Kind::GitStatusClosed, "close").await 197 launch_status(id, offline, Kind::GitStatusClosed, "closed", None).await
191} 198}
192 199
193pub async fn launch_reopen(id: &str, offline: bool) -> Result<()> { 200pub async fn launch_reopen(id: &str, offline: bool) -> Result<()> {
194 launch_status(id, offline, Kind::GitStatusOpen, "reopen").await 201 launch_status(id, offline, Kind::GitStatusOpen, "reopened", None).await
195} 202}
196 203
197pub async fn launch_ready(id: &str, offline: bool) -> Result<()> { 204pub async fn launch_ready(id: &str, offline: bool) -> Result<()> {
198 launch_status(id, offline, Kind::GitStatusOpen, "mark as ready").await 205 launch_status(id, offline, Kind::GitStatusOpen, "marked as ready", None).await
206}
207
208pub async fn launch_draft(id: &str, offline: bool) -> Result<()> {
209 launch_status(id, offline, Kind::GitStatusDraft, "converted to draft", None).await
199} 210}