From 17341b3af6e09694a964cc0ff84038d1c3d26880 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 8 Mar 2026 21:02:52 -0300 Subject: [PATCH] git: issue and patch listing unified in base command. --- git.go | 602 +++++++++++++++++++++++++++++---------------------------- 1 file changed, 303 insertions(+), 299 deletions(-) diff --git a/git.go b/git.go index f2dd02f..342b756 100644 --- a/git.go +++ b/git.go @@ -787,34 +787,112 @@ aside from those, there is also: }, }, { - Name: "patch", - Usage: "patch-related operations", - ArgsUsage: "[id-prefix]", + Name: "patch", + Usage: "patch-related operations", + Description: "when called directly, lists open patches; with an patch id prefix, displays that patch with threaded discussions.", + ArgsUsage: "[id-prefix]", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "applied", + Usage: "list only applied/merged patches", + }, + &cli.BoolFlag{ + Name: "closed", + Usage: "list only closed patches", + }, + &cli.BoolFlag{ + Name: "all", + Usage: "list all patches, including applied and closed", + }, + }, Action: func(ctx context.Context, c *cli.Command) error { - prefix := strings.TrimSpace(c.Args().First()) repo, err := readGitRepositoryFromConfig() if err != nil { return err } - patches, err := fetchGitRepoRelatedEvents(ctx, repo, 1617) + events, err := fetchGitRepoRelatedEvents(ctx, repo, 1617) if err != nil { return err } - statuses, err := fetchIssueStatus(ctx, repo, patches) - if err != nil { - return err - } + prefix := strings.TrimSpace(c.Args().First()) + if prefix == "" { + // list + statuses, err := fetchIssueStatus(ctx, repo, events) + if err != nil { + return err + } - evt, err := findEventByPrefix(patches, prefix) - if err != nil { - return err - } + if len(events) == 0 { + log("no patches found\n") + return nil + } - appender := &lineAppender{} - printGitDiscussionMetadata(ctx, appender, evt, statusLabelForEvent(evt.ID, statuses, false), false) - return showTextWithGitPager(evt.Content) + showApplied := c.Bool("applied") + showClosed := c.Bool("closed") + showAll := c.Bool("all") + + // preload metadata from everybody + wg := sync.WaitGroup{} + for _, evt := range events { + wg.Go(func() { + sys.FetchProfileMetadata(ctx, evt.PubKey) + }) + } + wg.Wait() + + // now render + for _, evt := range events { + id := evt.ID.Hex() + + status := statusLabelForEvent(evt.ID, statuses, false) + if !showAll { + if showApplied || showClosed { + isApplied := status == "applied/merged" + isClosed := status == "closed" + if !(showApplied && isApplied || showClosed && isClosed) { + continue + } + } else if status == "applied/merged" || status == "closed" { + continue + } + } + + date := evt.CreatedAt.Time().Format(time.DateOnly) + subject := patchSubjectPreview(evt, 72) + statusDisplayText := status + if status == "applied/merged" { + statusDisplayText = "applied" + } + statusDisplay := colorizeGitStatus(statusDisplayText) + + if status == "applied/merged" { + if statusEvt, ok := statuses[evt.ID]; ok { + if commit := patchAppliedCommitPreview(statusEvt); commit != "" { + statusDisplay = statusDisplay + color.HiBlackString(" (%s)", commit) + } + } + } + + stdout(color.CyanString(id[:6]), statusDisplay, color.HiBlackString(date), color.HiBlueString(authorPreview(ctx, evt.PubKey)), color.HiWhiteString(subject)) + } + + return nil + } else { + // view single + evt, err := findEventByPrefix(events, prefix) + if err != nil { + return err + } + + statuses, err := fetchIssueStatus(ctx, repo, []nostr.RelayEvent{evt}) + if err != nil { + return err + } + + return showGitDiscussionWithComments(ctx, repo, evt, statusLabelForEvent(evt.ID, statuses, false)) + } }, Commands: []*cli.Command{ { @@ -905,93 +983,11 @@ aside from those, there is also: }, }, { - Name: "list", - Usage: "list patches found in repository relays", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "applied", - Usage: "list only applied/merged patches", - }, - &cli.BoolFlag{ - Name: "closed", - Usage: "list only closed patches", - }, - &cli.BoolFlag{ - Name: "all", - Usage: "list all patches, including applied and closed", - }, - }, + Name: "reply", + Usage: "reply to a patch with a NIP-22 comment event", + ArgsUsage: "", Action: func(ctx context.Context, c *cli.Command) error { - repo, err := readGitRepositoryFromConfig() - if err != nil { - return err - } - - events, err := fetchGitRepoRelatedEvents(ctx, repo, 1617) - if err != nil { - return err - } - - statuses, err := fetchIssueStatus(ctx, repo, events) - if err != nil { - return err - } - - if len(events) == 0 { - log("no patches found\n") - return nil - } - - showApplied := c.Bool("applied") - showClosed := c.Bool("closed") - showAll := c.Bool("all") - - // preload metadata from everybody - wg := sync.WaitGroup{} - for _, evt := range events { - wg.Go(func() { - sys.FetchProfileMetadata(ctx, evt.PubKey) - }) - } - wg.Wait() - - // now render - for _, evt := range events { - id := evt.ID.Hex() - - status := statusLabelForEvent(evt.ID, statuses, false) - if !showAll { - if showApplied || showClosed { - isApplied := status == "applied/merged" - isClosed := status == "closed" - if !(showApplied && isApplied || showClosed && isClosed) { - continue - } - } else if status == "applied/merged" || status == "closed" { - continue - } - } - - date := evt.CreatedAt.Time().Format(time.DateOnly) - subject := patchSubjectPreview(evt.Content, 72) - statusDisplayText := status - if status == "applied/merged" { - statusDisplayText = "applied" - } - statusDisplay := colorizeGitStatus(statusDisplayText) - - if status == "applied/merged" { - if statusEvt, ok := statuses[evt.ID]; ok { - if commit := patchAppliedCommitPreview(statusEvt); commit != "" { - statusDisplay = statusDisplay + color.HiBlackString(" (%s)", commit) - } - } - } - - stdout(color.CyanString(id[:6]), statusDisplay, color.HiBlackString(date), color.HiBlueString(authorPreview(ctx, evt.PubKey)), color.HiWhiteString(subject)) - } - - return nil + return gitDiscussionReply(ctx, c, 1617, "patch", patchSubjectPreview) }, }, { @@ -1116,47 +1112,92 @@ aside from those, there is also: }, }, { - Name: "issue", - Usage: "issue-related operations", - ArgsUsage: "[id-prefix]", + Name: "issue", + Usage: "issue-related operations", + Description: "when called directly, lists open issues; with an issue id prefix, displays that issue with threaded discussions.", + ArgsUsage: "[id-prefix]", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "closed", + Usage: "list only closed issues", + }, + &cli.BoolFlag{ + Name: "all", + Usage: "list all issues, including closed", + }, + }, Action: func(ctx context.Context, c *cli.Command) error { - prefix := strings.TrimSpace(c.Args().First()) repo, err := readGitRepositoryFromConfig() if err != nil { return err } - issues, err := fetchGitRepoRelatedEvents(ctx, repo, 1621) + events, err := fetchGitRepoRelatedEvents(ctx, repo, 1621) if err != nil { return err } - statuses, err := fetchIssueStatus(ctx, repo, issues) - if err != nil { - return err + prefix := strings.TrimSpace(c.Args().First()) + if prefix == "" { + // list + statuses, err := fetchIssueStatus(ctx, repo, events) + if err != nil { + return err + } + + if len(events) == 0 { + log("no issues found\n") + return nil + } + + showClosed := c.Bool("closed") + showAll := c.Bool("all") + + // preload metadata from everybody + wg := sync.WaitGroup{} + for _, evt := range events { + wg.Go(func() { + sys.FetchProfileMetadata(ctx, evt.PubKey) + }) + } + wg.Wait() + + // now render + for _, evt := range events { + id := evt.ID.Hex() + status := statusLabelForEvent(evt.ID, statuses, true) + if !showAll { + if showClosed { + if status != "closed" { + continue + } + } else if status == "closed" { + continue + } + } + + author := authorPreview(ctx, evt.PubKey) + + subject := issueSubjectPreview(evt, 72) + date := evt.CreatedAt.Time().Format(time.DateOnly) + stdout(color.CyanString(id[:6]), colorizeGitStatus(status), color.HiBlackString(date), color.HiBlueString(author), color.HiWhiteString(subject)) + } + + return nil + } else { + // view single + evt, err := findEventByPrefix(events, prefix) + if err != nil { + return err + } + + statuses, err := fetchIssueStatus(ctx, repo, []nostr.RelayEvent{evt}) + if err != nil { + return err + } + + return showGitDiscussionWithComments(ctx, repo, evt, statusLabelForEvent(evt.ID, statuses, true)) } - - evt, err := findEventByPrefix(issues, prefix) - if err != nil { - return err - } - - printGitDiscussionMetadata(ctx, os.Stdout, evt, statusLabelForEvent(evt.ID, statuses, true), true) - stdout("") - stdout(evt.Content) - - comments, err := fetchIssueComments(ctx, repo, evt.ID) - if err != nil { - return err - } - - if len(comments) > 0 { - stdout("") - stdout(color.CyanString("comments:")) - printIssueCommentsThreaded(ctx, os.Stdout, comments, evt.ID, true) - } - - return nil }, Commands: []*cli.Command{ { @@ -1221,131 +1262,7 @@ please fix Usage: "reply to an issue with a NIP-22 comment event", ArgsUsage: "", Action: func(ctx context.Context, c *cli.Command) error { - prefix := strings.TrimSpace(c.Args().First()) - if prefix == "" { - return fmt.Errorf("missing issue id prefix") - } - - kr, _, err := gatherKeyerFromArguments(ctx, c) - if err != nil { - return fmt.Errorf("failed to gather keyer: %w", err) - } - - repo, err := readGitRepositoryFromConfig() - if err != nil { - return err - } - - issues, err := fetchGitRepoRelatedEvents(ctx, repo, 1621) - if err != nil { - return err - } - - issueEvt, err := findEventByPrefix(issues, prefix) - if err != nil { - return err - } - - comments, err := fetchIssueComments(ctx, repo, issueEvt.ID) - if err != nil { - return err - } - - edited, err := editWithDefaultEditor( - "nak-git-issue-reply/NOTES_EDITMSG", - gitIssueReplyEditorTemplate(ctx, issueEvt, comments), - true, - ) - if err != nil { - return err - } - - content, parentEvt, err := parseIssueReplyContent(issueEvt, comments, edited) - if err != nil { - return err - } - if content == "" { - return fmt.Errorf("empty reply content, aborting") - } - - if parentEvt.ID == issueEvt.ID { - log("> replying to issue %s (%s)\n", - color.CyanString(issueEvt.ID.Hex()[:6]), - color.HiWhiteString(issueSubjectPreview(issueEvt, 72)), - ) - } else { - log("> replying to comment %s by %s on issue %s\n", - color.CyanString(parentEvt.ID.Hex()[:6]), - color.HiBlueString(authorPreview(ctx, parentEvt.PubKey)), - color.CyanString(issueEvt.ID.Hex()[:6]), - ) - } - - evt := nostr.Event{ - CreatedAt: nostr.Now(), - Kind: 1111, - Tags: nostr.Tags{ - nostr.Tag{"E", issueEvt.ID.Hex(), issueEvt.Relay.URL}, - nostr.Tag{"e", parentEvt.ID.Hex(), parentEvt.Relay.URL}, - nostr.Tag{"P", issueEvt.PubKey.Hex()}, - nostr.Tag{"p", parentEvt.PubKey.Hex()}, - }, - Content: content, - } - if err := kr.SignEvent(ctx, &evt); err != nil { - return fmt.Errorf("failed to sign issue reply event: %w", err) - } - if err := confirmGitEventToBeSent(evt, repo.Relays, "send this issue reply"); err != nil { - return err - } - - return publishGitEventToRepoRelays(ctx, evt, repo.Relays) - }, - }, - { - Name: "list", - Usage: "list issues found in repository relays", - Action: func(ctx context.Context, c *cli.Command) error { - repo, err := readGitRepositoryFromConfig() - if err != nil { - return err - } - - events, err := fetchGitRepoRelatedEvents(ctx, repo, 1621) - if err != nil { - return err - } - - statuses, err := fetchIssueStatus(ctx, repo, events) - if err != nil { - return err - } - - if len(events) == 0 { - log("no issues found\n") - return nil - } - - // preload metadata from everybody - wg := sync.WaitGroup{} - for _, evt := range events { - wg.Go(func() { - sys.FetchProfileMetadata(ctx, evt.PubKey) - }) - } - wg.Wait() - - for _, evt := range events { - id := evt.ID.Hex() - status := statusLabelForEvent(evt.ID, statuses, true) - author := authorPreview(ctx, evt.PubKey) - - subject := issueSubjectPreview(evt, 72) - date := evt.CreatedAt.Time().Format(time.DateOnly) - stdout(color.CyanString(id[:6]), colorizeGitStatus(status), color.HiBlackString(date), color.HiBlueString(author), color.HiWhiteString(subject)) - } - - return nil + return gitDiscussionReply(ctx, c, 1621, "issue", issueSubjectPreview) }, }, }, @@ -1635,12 +1552,12 @@ func fetchIssueStatus( return latest, nil } -func fetchIssueComments(ctx context.Context, repo nip34.Repository, issueID nostr.ID) ([]nostr.RelayEvent, error) { +func fetchGitDiscussionComments(ctx context.Context, repo nip34.Repository, discussionID nostr.ID) ([]nostr.RelayEvent, error) { comments := make([]nostr.RelayEvent, 0, 15) for ie := range sys.Pool.FetchMany(ctx, repo.Relays, nostr.Filter{ Kinds: []nostr.Kind{1111}, Tags: nostr.TagMap{ - "E": []string{issueID.Hex()}, + "E": []string{discussionID.Hex()}, }, Limit: 500, }, nostr.SubscriptionOptions{Label: "nak-git"}) { @@ -1652,15 +1569,126 @@ func fetchIssueComments(ctx context.Context, repo nip34.Repository, issueID nost return comments, nil } -func printIssueCommentsThreaded( +func showGitDiscussionWithComments( + ctx context.Context, + repo nip34.Repository, + evt nostr.RelayEvent, + status string, +) error { + comments, err := fetchGitDiscussionComments(ctx, repo, evt.ID) + if err != nil { + return err + } + + printGitDiscussionMetadata(ctx, os.Stdout, evt, status, true) + stdout("") + stdout(evt.Content) + + if len(comments) > 0 { + stdout("") + stdout(color.CyanString("comments:")) + printGitDiscussionCommentsThreaded(ctx, os.Stdout, comments, evt.ID, true) + } + + return nil +} + +func gitDiscussionReply( + ctx context.Context, + c *cli.Command, + discussionKind nostr.Kind, + discussionName string, + subjectPreview func(nostr.RelayEvent, int) string, +) error { + prefix := strings.TrimSpace(c.Args().First()) + if prefix == "" { + return fmt.Errorf("missing %s id prefix", discussionName) + } + + kr, _, err := gatherKeyerFromArguments(ctx, c) + if err != nil { + return fmt.Errorf("failed to gather keyer: %w", err) + } + + repo, err := readGitRepositoryFromConfig() + if err != nil { + return err + } + + discussions, err := fetchGitRepoRelatedEvents(ctx, repo, discussionKind) + if err != nil { + return err + } + + discussionEvt, err := findEventByPrefix(discussions, prefix) + if err != nil { + return err + } + + comments, err := fetchGitDiscussionComments(ctx, repo, discussionEvt.ID) + if err != nil { + return err + } + + edited, err := editWithDefaultEditor( + fmt.Sprintf("nak-git-%s-reply/NOTES_EDITMSG", discussionName), + gitDiscussionReplyEditorTemplate(ctx, discussionEvt, comments), + true, + ) + if err != nil { + return err + } + + content, parentEvt, err := parseGitDiscussionReplyContent(discussionEvt, comments, edited) + if err != nil { + return err + } + + if parentEvt.ID == discussionEvt.ID { + log("> replying to %s %s (%s)\n", + discussionName, + color.CyanString(discussionEvt.ID.Hex()[:6]), + color.HiWhiteString(subjectPreview(discussionEvt, 72)), + ) + } else { + log("> replying to comment %s by %s on %s %s\n", + color.CyanString(parentEvt.ID.Hex()[:6]), + color.HiBlueString(authorPreview(ctx, parentEvt.PubKey)), + discussionName, + color.CyanString(discussionEvt.ID.Hex()[:6]), + ) + } + + evt := nostr.Event{ + CreatedAt: nostr.Now(), + Kind: 1111, + Tags: nostr.Tags{ + nostr.Tag{"E", discussionEvt.ID.Hex(), discussionEvt.Relay.URL}, + nostr.Tag{"e", parentEvt.ID.Hex(), parentEvt.Relay.URL}, + nostr.Tag{"P", discussionEvt.PubKey.Hex()}, + nostr.Tag{"p", parentEvt.PubKey.Hex()}, + }, + Content: content, + } + if err := kr.SignEvent(ctx, &evt); err != nil { + return fmt.Errorf("failed to sign %s reply event: %w", discussionName, err) + } + if err := confirmGitEventToBeSent(evt, repo.Relays, fmt.Sprintf("send this %s reply", discussionName)); err != nil { + return err + } + + return publishGitEventToRepoRelays(ctx, evt, repo.Relays) +} + +func printGitDiscussionCommentsThreaded( ctx context.Context, w io.Writer, comments []nostr.RelayEvent, - issueID nostr.ID, + discussionID nostr.ID, withColor bool, ) { byID := make(map[nostr.ID]struct{}, len(comments)+1) - byID[issueID] = struct{}{} + byID[discussionID] = struct{}{} for _, c := range comments { byID[c.ID] = struct{}{} } @@ -1711,7 +1739,7 @@ func printIssueCommentsThreaded( } } - render(issueID, 0) + render(discussionID, 0) } func findEventByPrefix(events []nostr.RelayEvent, prefix string) (nostr.RelayEvent, error) { @@ -1770,8 +1798,8 @@ func printGitDiscussionMetadata( var patchPrefixRe = regexp.MustCompile(`(?i)^\[patch[^\]]*\]\s*`) -func patchSubjectPreview(content string, maxChars int) string { - for _, line := range strings.Split(content, "\n") { +func patchSubjectPreview(evt nostr.RelayEvent, maxChars int) string { + for _, line := range strings.Split(evt.Content, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "Subject:") { continue @@ -1847,8 +1875,8 @@ func parseIssueCreateContent(content string) (subject string, body string, err e return subject, body, nil } -func parseIssueReplyContent(issue nostr.RelayEvent, comments []nostr.RelayEvent, edited string) (string, nostr.RelayEvent, error) { - currentParent := issue +func parseGitDiscussionReplyContent(discussion nostr.RelayEvent, comments []nostr.RelayEvent, edited string) (string, nostr.RelayEvent, error) { + currentParent := discussion selectedParent := nostr.ZeroID inComments := false @@ -1858,7 +1886,7 @@ func parseIssueReplyContent(issue nostr.RelayEvent, comments []nostr.RelayEvent, if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "#>") { inComments = false - currentParent = issue + currentParent = discussion continue } @@ -1870,7 +1898,7 @@ func parseIssueReplyContent(issue nostr.RelayEvent, comments []nostr.RelayEvent, quoted := strings.TrimSpace(strings.TrimPrefix(line, "#>")) if strings.EqualFold(quoted, "comments:") { inComments = true - currentParent = issue + currentParent = discussion continue } @@ -1910,8 +1938,8 @@ func parseIssueReplyContent(issue nostr.RelayEvent, comments []nostr.RelayEvent, return "", nostr.RelayEvent{}, fmt.Errorf("empty reply content, aborting") } - if selectedParent == nostr.ZeroID || selectedParent == issue.ID { - return content, issue, nil + if selectedParent == nostr.ZeroID || selectedParent == discussion.ID { + return content, discussion, nil } for _, comment := range comments { @@ -1989,7 +2017,7 @@ func shortCommitID(commit string, n int) string { return commit[:n] } -func gitIssueReplyEditorTemplate(ctx context.Context, issue nostr.RelayEvent, comments []nostr.RelayEvent) string { +func gitDiscussionReplyEditorTemplate(ctx context.Context, discussion nostr.RelayEvent, comments []nostr.RelayEvent) string { lines := []string{ "# write your reply here.", "# lines starting with '#' are ignored.", @@ -1998,15 +2026,15 @@ func gitIssueReplyEditorTemplate(ctx context.Context, issue nostr.RelayEvent, co appender := &lineAppender{lines, "#> "} - printGitDiscussionMetadata(ctx, appender, issue, "", false) + printGitDiscussionMetadata(ctx, appender, discussion, "", false) - for _, line := range strings.Split(issue.Content, "\n") { + for _, line := range strings.Split(discussion.Content, "\n") { appender.lines = append(appender.lines, "#> "+line) } if len(comments) > 0 { appender.lines = append(appender.lines, "#> ", "#> comments:") - printIssueCommentsThreaded(ctx, appender, comments, issue.ID, false) + printGitDiscussionCommentsThreaded(ctx, appender, comments, discussion.ID, false) appender.lines = append(appender.lines, "", "# comment below an existing comment to send yours as a reply to it.") } @@ -2041,30 +2069,6 @@ func colorizeGitStatus(status string) string { } } -func showTextWithGitPager(text string) error { - pagerData, err := exec.Command("git", "var", "GIT_PAGER").Output() - if err != nil { - stdout(text) - return nil - } - - pager := strings.TrimSpace(string(pagerData)) - if pager == "" || pager == "cat" { - stdout(text) - return nil - } - - cmd := exec.Command("sh", "-c", pager) - cmd.Stdin = strings.NewReader(text) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - stdout(text) - } - - return nil -} - func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.RepositoryState, error) { // read current nip34.json localConfig, err := readNip34ConfigFile("")