'group forum' with the ui like 'nak git issue'.

This commit is contained in:
fiatjaf
2026-03-12 19:03:20 -03:00
parent 4967db13a1
commit 5f4efdbc69
3 changed files with 637 additions and 305 deletions

315
git.go
View File

@@ -3,7 +3,6 @@ package main
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@@ -15,7 +14,6 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip22"
"fiatjaf.com/nostr/nip34"
"github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
@@ -891,7 +889,7 @@ aside from those, there is also:
return err
}
return showGitDiscussionWithComments(ctx, repo, evt, statusLabelForEvent(evt.ID, statuses, false))
return showThreadWithComments(ctx, repo.Relays, evt, statusLabelForEvent(evt.ID, statuses, false), nil)
}
},
Commands: []*cli.Command{
@@ -1205,7 +1203,7 @@ aside from those, there is also:
return err
}
return showGitDiscussionWithComments(ctx, repo, evt, statusLabelForEvent(evt.ID, statuses, true))
return showThreadWithComments(ctx, repo.Relays, evt, statusLabelForEvent(evt.ID, statuses, true), nil)
}
},
Commands: []*cli.Command{
@@ -1218,6 +1216,11 @@ aside from those, there is also:
return fmt.Errorf("failed to gather keyer: %w", err)
}
_, selfName, selfNpub, err := keyerIdentity(ctx, kr)
if err != nil {
return fmt.Errorf("failed to get current identity: %w", err)
}
repo, err := readGitRepositoryFromConfig()
if err != nil {
return err
@@ -1225,7 +1228,8 @@ aside from those, there is also:
content, err := editWithDefaultEditor(
"nak-git-issue/NOTES_EDITMSG",
strings.TrimSpace(`
strings.TrimSpace(fmt.Sprintf(`# creating as '%s' ('%s')
# creating issue on repository '%s'
# the first line will be used as the issue subject
everything is broken
@@ -1233,7 +1237,7 @@ everything is broken
please fix
# lines starting with '#' are ignored
`),
`, selfName, selfNpub, repo.ID)),
true,
)
if err != nil {
@@ -1569,47 +1573,6 @@ func fetchIssueStatus(
return latest, nil
}
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{discussionID.Hex()},
},
Limit: 500,
}, nostr.SubscriptionOptions{Label: "nak-git"}) {
comments = append(comments, ie)
}
slices.SortFunc(comments, nostr.CompareRelayEvent)
return comments, nil
}
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,
@@ -1627,6 +1590,11 @@ func gitDiscussionReply(
return fmt.Errorf("failed to gather keyer: %w", err)
}
_, selfName, selfNpub, err := keyerIdentity(ctx, kr)
if err != nil {
return fmt.Errorf("failed to get current identity: %w", err)
}
repo, err := readGitRepositoryFromConfig()
if err != nil {
return err
@@ -1642,21 +1610,31 @@ func gitDiscussionReply(
return err
}
comments, err := fetchGitDiscussionComments(ctx, repo, discussionEvt.ID)
comments, err := fetchThreadComments(ctx, repo.Relays, discussionEvt.ID, nil)
if err != nil {
return err
}
subject := subjectPreview(discussionEvt, 72)
if subject == "" {
subject = "<untitled>"
}
pm := sys.FetchProfileMetadata(ctx, discussionEvt.PubKey)
headerLines := []string{
fmt.Sprintf("commenting as '%s' ('%s')", selfName, selfNpub),
fmt.Sprintf("commenting on %s '%s' '%s' by '%s' ('%s') on repository '%s'", discussionName, discussionEvt.ID.Hex()[:6], subject, pm.ShortName(), pm.NpubShort(), repo.ID),
}
edited, err := editWithDefaultEditor(
fmt.Sprintf("nak-git-%s-reply/NOTES_EDITMSG", discussionName),
gitDiscussionReplyEditorTemplate(ctx, discussionEvt, comments),
threadReplyEditorTemplate(ctx, headerLines, discussionEvt, comments),
true,
)
if err != nil {
return err
}
content, parentEvt, err := parseGitDiscussionReplyContent(discussionEvt, comments, edited)
content, parentEvt, err := parseThreadReplyContent(discussionEvt, comments, edited)
if err != nil {
return err
}
@@ -1789,122 +1767,6 @@ func ensureGitRepositoryMaintainer(ctx context.Context, kr nostr.Keyer, repo nip
return pubkey, nil
}
func printGitDiscussionCommentsThreaded(
ctx context.Context,
w io.Writer,
comments []nostr.RelayEvent,
discussionID nostr.ID,
withColor bool,
) {
byID := make(map[nostr.ID]struct{}, len(comments)+1)
byID[discussionID] = struct{}{}
for _, c := range comments {
byID[c.ID] = struct{}{}
}
// preload metadata from everybody
wg := sync.WaitGroup{}
children := make(map[nostr.ID][]nostr.RelayEvent, len(comments)+1)
for _, c := range comments {
wg.Go(func() {
sys.FetchProfileMetadata(ctx, c.PubKey)
})
parent, ok := nip22.GetImmediateParent(c.Event.Tags).(nostr.EventPointer)
if !ok {
continue
}
if _, ok := byID[parent.ID]; ok {
children[parent.ID] = append(children[parent.ID], c)
}
}
for parent := range children {
slices.SortFunc(children[parent], nostr.CompareRelayEvent)
}
wg.Wait()
var render func(parent nostr.ID, depth int)
render = func(parent nostr.ID, depth int) {
for _, c := range children[parent] {
indent := strings.Repeat(" ", depth)
author := authorPreview(ctx, c.PubKey)
created := c.CreatedAt.Time().Format(time.DateTime)
if withColor {
fmt.Fprintln(w, indent+color.CyanString("["+c.ID.Hex()[0:6]+"]"), color.HiBlueString(author), color.HiBlackString(created))
} else {
fmt.Fprintln(w, indent+"["+c.ID.Hex()[0:6]+"] "+author+" "+created)
}
for _, line := range strings.Split(c.Content, "\n") {
fmt.Fprintln(w, indent+" "+line)
}
fmt.Fprintln(w, indent+"")
render(c.ID, depth+1)
}
}
render(discussionID, 0)
}
func findEventByPrefix(events []nostr.RelayEvent, prefix string) (nostr.RelayEvent, error) {
prefix = strings.ToLower(strings.TrimSpace(prefix))
if prefix == "" {
return nostr.RelayEvent{}, fmt.Errorf("missing event id prefix")
}
matchCount := 0
matched := nostr.RelayEvent{}
for _, evt := range events {
if strings.HasPrefix(evt.ID.Hex(), prefix) {
matched = evt
matchCount++
}
}
if matchCount == 0 {
return nostr.RelayEvent{}, fmt.Errorf("no event found with id prefix '%s'", prefix)
}
if matchCount > 1 {
return nostr.RelayEvent{}, fmt.Errorf("id prefix '%s' is ambiguous", prefix)
}
return matched, nil
}
func printGitDiscussionMetadata(
ctx context.Context,
w io.Writer,
evt nostr.RelayEvent,
status string,
withColors bool,
) {
label := func(s string) string { return s }
value := func(s string) string { return s }
statusValue := func(s string) string { return s }
if withColors {
label = func(s string) string { return color.CyanString(s) }
value = func(s string) string { return color.HiWhiteString(s) }
statusValue = colorizeGitStatus
}
fmt.Fprintln(w, label("id:"), value(evt.ID.Hex()))
fmt.Fprintln(w, label("kind:"), value(fmt.Sprintf("%d", evt.Kind.Num())))
fmt.Fprintln(w, label("author:"), value(authorPreview(ctx, evt.PubKey)))
fmt.Fprintln(w, label("created:"), value(evt.CreatedAt.Time().Format(time.RFC3339)))
if status != "" {
fmt.Fprintln(w, label("status:"), statusValue(status))
}
if subject := evt.Tags.Find("subject"); subject != nil && len(subject) >= 2 {
fmt.Fprintln(w, label("subject:"), value(subject[1]))
fmt.Fprintln(w, "")
}
}
var patchPrefixRe = regexp.MustCompile(`(?i)^\[patch[^\]]*\]\s*`)
func patchSubjectPreview(evt nostr.RelayEvent, maxChars int) string {
@@ -1984,90 +1846,6 @@ func parseIssueCreateContent(content string) (subject string, body string, err e
return subject, body, nil
}
func parseGitDiscussionReplyContent(discussion nostr.RelayEvent, comments []nostr.RelayEvent, edited string) (string, nostr.RelayEvent, error) {
currentParent := discussion
selectedParent := nostr.ZeroID
inComments := false
replyb := strings.Builder{}
for _, line := range strings.Split(edited, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "#>") {
inComments = false
currentParent = discussion
continue
}
if replyb.Len() == 0 && line == "" {
continue
}
if strings.HasPrefix(line, "#>") {
quoted := strings.TrimSpace(strings.TrimPrefix(line, "#>"))
if quoted == "comments:" {
inComments = true
currentParent = discussion
continue
}
// keep track of which comment the reply body shows up below of
// so we can assign it as a reply to that specifically
fields := strings.Fields(quoted)
if inComments && len(fields) > 0 && fields[0][0] == '[' && fields[0][len(fields[0])-1] == ']' {
currId := fields[0][1 : len(fields[0])-1]
for _, comment := range comments {
if strings.HasPrefix(comment.ID.Hex(), currId) {
currentParent = comment
break
}
}
}
continue
}
// if we reach here this is a line for the reply input from the user
replyb.WriteString(line)
replyb.WriteByte('\n')
if line == "" {
continue
}
if selectedParent != nostr.ZeroID && selectedParent != currentParent.ID {
return "", nostr.RelayEvent{}, fmt.Errorf("can only reply to one comment or create a top-level comment, got replies to both %s and %s", selectedParent.Hex()[0:6], currentParent.ID.Hex()[0:6])
}
selectedParent = currentParent.ID
}
content := strings.TrimSpace(replyb.String())
if content == "" {
return "", nostr.RelayEvent{}, fmt.Errorf("empty reply content, aborting")
}
if selectedParent == nostr.ZeroID || selectedParent == discussion.ID {
return content, discussion, nil
}
for _, comment := range comments {
if comment.ID == selectedParent {
return content, comment, nil
}
}
panic("selected reply parent not found (this never happens)")
}
func authorPreview(ctx context.Context, pubkey nostr.PubKey) string {
meta := sys.FetchProfileMetadata(ctx, pubkey)
if meta.Name != "" {
return meta.ShortName() + " (" + meta.NpubShort() + ")"
}
return meta.NpubShort()
}
func statusLabelForEvent(id nostr.ID, statuses map[nostr.ID]nostr.Event, isIssue bool) string {
statusEvt, ok := statuses[id]
if !ok {
@@ -2123,43 +1901,6 @@ func shortCommitID(commit string, n int) string {
return commit[:n]
}
func gitDiscussionReplyEditorTemplate(ctx context.Context, discussion nostr.RelayEvent, comments []nostr.RelayEvent) string {
lines := []string{
"# write your reply here.",
"# lines starting with '#' are ignored.",
"",
}
appender := &lineAppender{lines, "#> "}
printGitDiscussionMetadata(ctx, appender, discussion, "", false)
for _, line := range strings.Split(discussion.Content, "\n") {
appender.lines = append(appender.lines, "#> "+line)
}
if len(comments) > 0 {
appender.lines = append(appender.lines, "#> ", "#> comments:")
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.")
}
return strings.Join(appender.lines, "\n")
}
type lineAppender struct {
lines []string
prefix string
}
func (l *lineAppender) Write(b []byte) (int, error) {
for _, line := range strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") {
line = strings.TrimRight(line, " ")
l.lines = append(l.lines, l.prefix+line)
}
return len(b), nil
}
func colorizeGitStatus(status string) string {
switch status {
case "open":