From b0ad2fd720d0cd335c07f22767844f571e3306ff Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 5 Mar 2026 12:02:09 +0000 Subject: feat(status): add pr draft, issue resolved, and --reason for issue close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `ngit pr draft ` to convert a PR back to draft (kind-1632). Add `ngit issue resolved [--reason ]` to mark an issue as fixed (kind-1631 GitStatusApplied), distinct from close which signals wontfix/duplicate/invalid. Add `--reason ` to `ngit issue close` — stored in event content. Also fix success/error message wording in pr_status and issue_status to use consistent past-tense action strings. --- CHANGELOG.md | 11 +++++++---- skills/ngit/SKILL.md | 4 +++- src/bin/ngit/cli.rs | 29 ++++++++++++++++++++++++++++- src/bin/ngit/main.rs | 11 +++++++++-- src/bin/ngit/sub_commands/issue_status.rs | 30 ++++++++++++++++++++++-------- src/bin/ngit/sub_commands/pr_status.rs | 29 ++++++++++++++++++++--------- 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 - `ngit issue label --label [--label ...]` — 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 - `ngit pr label --label [--label ...]` — 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 - `ngit account whoami` — show the currently logged-in account(s) -- `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 +- `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 - `ngit pr view ` — view a PR with its full details and all comments (author, timestamp, body) in chronological order - `ngit pr close ` / `ngit pr reopen ` — change PR status (author or maintainer only) - `ngit pr ready ` — mark a draft PR as ready for review (author or maintainer only) +- `ngit pr draft ` — convert a PR back to draft (author or maintainer only) - `ngit pr comment --body ` — post a NIP-22 comment on a PR - `ngit pr merge [--squash]` — merge a PR branch and publish a `GitStatusApplied` event (maintainer only); prints a reminder to push afterwards -- `ngit issue` subcommand group expanded: `list`, `view`, `create`, `close`, `reopen`, `comment` +- `ngit issue` subcommand group: `list`, `view`, `create`, `close`, `resolved`, `reopen`, `comment`, `label` - `ngit issue view ` — view an issue with its full details and all comments (author, timestamp, body) in chronological order - `ngit issue create --title [--body ] [--label ...]` — publish a NIP-34 GitIssue event -- `ngit issue close ` / `ngit issue reopen ` — change issue status (author or maintainer only) +- `ngit issue close [--reason ]` — close an issue without resolving it; reason is stored in the event content (author or maintainer only) +- `ngit issue resolved [--reason ]` — 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) +- `ngit issue reopen ` — reopen a closed or resolved issue (author or maintainer only) - `ngit issue comment --body ` — post a NIP-22 comment on an issue -- `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 `` positional argument to show full details of a specific issue; hashtags are shown at the end of each row +- `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 `` positional argument to show full details of a specific issue - `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` ## [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 ngit pr close ngit pr reopen ngit pr ready # mark draft as ready for review +ngit pr draft # convert back to draft ngit pr label --label bug --label enhancement ``` @@ -137,7 +138,8 @@ ngit issue view --json ngit issue view --json --comments ngit issue comment --body "Reproduced on v2.1" ngit issue comment --body "Thanks!" --reply-to -ngit issue close +ngit issue close --reason "wontfix" # closed without resolution +ngit issue resolved --reason "fixed in abc123" ngit issue reopen ngit issue label --label bug --label enhancement ``` 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 { #[arg(long)] offline: bool, }, + /// convert a PR back to draft (author or maintainer only) + Draft { + /// Proposal event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, /// add a comment to a PR Comment { /// Proposal event-id (hex) or nevent (bech32) @@ -373,11 +382,29 @@ pub enum IssueCommands { #[arg(long = "label", value_name = "LABEL")] labels: Vec, }, - /// close an issue (author or maintainer only) + /// close an issue without resolving it (author or maintainer only) Close { /// Issue event-id (hex) or nevent (bech32) #[arg(value_name = "ID|nevent")] id: String, + /// Optional reason (e.g. wontfix, duplicate, invalid) + #[arg(long)] + reason: Option, + /// Use local cache only, skip network fetch + #[arg(long)] + offline: bool, + }, + /// mark an issue as resolved (author or maintainer only) + #[command( + 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" + )] + Resolved { + /// Issue event-id (hex) or nevent (bech32) + #[arg(value_name = "ID|nevent")] + id: String, + /// Optional reason or resolution summary + #[arg(long)] + reason: Option, /// Use local cache only, skip network fetch #[arg(long)] 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() { PrCommands::Ready { id, offline } => { sub_commands::pr_status::launch_ready(id, *offline).await } + PrCommands::Draft { id, offline } => { + sub_commands::pr_status::launch_draft(id, *offline).await + } PrCommands::Comment { id, body, @@ -178,8 +181,12 @@ async fn main() { sub_commands::issue_create::launch(title.clone(), body.clone(), labels.clone()) .await } - IssueCommands::Close { id, offline } => { - sub_commands::issue_status::launch_close(id, *offline).await + IssueCommands::Close { id, reason, offline } => { + sub_commands::issue_status::launch_close(id, *offline, reason.as_deref()).await + } + IssueCommands::Resolved { id, reason, offline } => { + sub_commands::issue_status::launch_resolved(id, *offline, reason.as_deref()) + .await } IssueCommands::Reopen { id, offline } => { 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 { } #[allow(clippy::too_many_lines)] -async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> Result<()> { +async fn launch_status( + id: &str, + offline: bool, + new_kind: Kind, + action: &str, + reason: Option<&str>, +) -> Result<()> { let event_id = parse_event_id(id)?; 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) -> // Only author or maintainer may change status if issue.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { - bail!("only the issue author or a repository maintainer can {action} an issue"); + bail!("only the issue author or a repository maintainer can change the status of an issue"); } // Fetch existing statuses to check current state @@ -96,6 +102,7 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> let status_str = match new_kind { Kind::GitStatusOpen => "open", Kind::GitStatusClosed => "closed", + Kind::GitStatusApplied => "resolved", _ => "unknown", }; println!("issue is already {status_str}"); @@ -105,6 +112,7 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> let alt_text = match new_kind { Kind::GitStatusOpen => "issue reopened", Kind::GitStatusClosed => "issue closed", + Kind::GitStatusApplied => "issue resolved", _ => "issue status updated", }; @@ -112,8 +120,10 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> repo_ref.maintainers.iter().copied().collect(); public_keys.insert(issue.pubkey); + let content = reason.unwrap_or("").to_string(); + let status_event = sign_event( - EventBuilder::new(new_kind, "").tags( + EventBuilder::new(new_kind, content).tags( [ vec![ Tag::custom( @@ -147,7 +157,7 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> .concat(), ), &signer, - format!("{action} issue"), + format!("issue {action}"), ) .await?; @@ -165,14 +175,18 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> ) .await?; - println!("issue {} {}d", &event_id.to_hex()[..8], action,); + println!("issue {} {action}", &event_id.to_hex()[..8]); Ok(()) } -pub async fn launch_close(id: &str, offline: bool) -> Result<()> { - launch_status(id, offline, Kind::GitStatusClosed, "close").await +pub async fn launch_close(id: &str, offline: bool, reason: Option<&str>) -> Result<()> { + launch_status(id, offline, Kind::GitStatusClosed, "closed", reason).await } pub async fn launch_reopen(id: &str, offline: bool) -> Result<()> { - launch_status(id, offline, Kind::GitStatusOpen, "reopen").await + launch_status(id, offline, Kind::GitStatusOpen, "reopened", None).await +} + +pub async fn launch_resolved(id: &str, offline: bool, reason: Option<&str>) -> Result<()> { + launch_status(id, offline, Kind::GitStatusApplied, "resolved", reason).await } 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 { } #[allow(clippy::too_many_lines)] -async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> Result<()> { +async fn launch_status( + id: &str, + offline: bool, + new_kind: Kind, + action: &str, + reason: Option<&str>, +) -> Result<()> { let event_id = parse_event_id(id)?; 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) -> // Only author or maintainer may change status if proposal.pubkey != user_pubkey && !repo_ref.maintainers.contains(&user_pubkey) { - bail!("only the PR author or a repository maintainer can {action} a PR"); + bail!("only the PR author or a repository maintainer can change the status of a PR"); } // Fetch existing statuses to check current state @@ -124,8 +130,10 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> repo_ref.maintainers.iter().copied().collect(); public_keys.insert(proposal.pubkey); + let content = reason.unwrap_or("").to_string(); + let status_event = sign_event( - EventBuilder::new(new_kind, "").tags( + EventBuilder::new(new_kind, content).tags( [ vec![ Tag::custom( @@ -159,7 +167,7 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> .concat(), ), &signer, - format!("{action} PR"), + format!("PR {action}"), ) .await?; @@ -178,22 +186,25 @@ async fn launch_status(id: &str, offline: bool, new_kind: Kind, action: &str) -> .await?; println!( - "PR {} {}d: {}", + "PR {} {action}: {}", &event_id.to_hex()[..8], - action, proposal.pubkey.to_bech32().unwrap_or_default() ); Ok(()) } pub async fn launch_close(id: &str, offline: bool) -> Result<()> { - launch_status(id, offline, Kind::GitStatusClosed, "close").await + launch_status(id, offline, Kind::GitStatusClosed, "closed", None).await } pub async fn launch_reopen(id: &str, offline: bool) -> Result<()> { - launch_status(id, offline, Kind::GitStatusOpen, "reopen").await + launch_status(id, offline, Kind::GitStatusOpen, "reopened", None).await } pub async fn launch_ready(id: &str, offline: bool) -> Result<()> { - launch_status(id, offline, Kind::GitStatusOpen, "mark as ready").await + launch_status(id, offline, Kind::GitStatusOpen, "marked as ready", None).await +} + +pub async fn launch_draft(id: &str, offline: bool) -> Result<()> { + launch_status(id, offline, Kind::GitStatusDraft, "converted to draft", None).await } -- cgit v1.2.3