mirror of
https://github.com/fiatjaf/nak.git
synced 2026-04-14 01:16:59 +02:00
git: issue replying in the middle of a thread.
This commit is contained in:
151
git.go
151
git.go
@@ -854,7 +854,11 @@ aside from those, there is also:
|
||||
return fmt.Errorf("patch too large: %d bytes (limit is 10240 bytes)", len(patchData))
|
||||
}
|
||||
|
||||
content, err := editWithDefaultEditor("nak-git-patch-*.patch", string(patchData))
|
||||
content, err := editWithDefaultEditor(
|
||||
"nak-git-patch.patch",
|
||||
string(patchData),
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1169,7 +1173,9 @@ aside from those, there is also:
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := editWithDefaultEditor("nak-git-issue-*.md", strings.TrimSpace(`
|
||||
content, err := editWithDefaultEditor(
|
||||
"nak-git-issue/NOTES_EDITMSG",
|
||||
strings.TrimSpace(`
|
||||
# the first line will be used as the issue subject
|
||||
everything is broken
|
||||
|
||||
@@ -1177,7 +1183,9 @@ everything is broken
|
||||
please fix
|
||||
|
||||
# lines starting with '#' are ignored
|
||||
`))
|
||||
`),
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1243,35 +1251,44 @@ please fix
|
||||
return err
|
||||
}
|
||||
|
||||
edited, err := editWithDefaultEditor("nak-git-issue-reply-*.md",
|
||||
gitIssueReplyEditorTemplate(ctx, issueEvt, comments))
|
||||
edited, err := editWithDefaultEditor(
|
||||
"nak-git-issue-reply/NOTES_EDITMSG",
|
||||
gitIssueReplyEditorTemplate(ctx, issueEvt, comments),
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
replyb := strings.Builder{}
|
||||
for _, line := range strings.Split(edited, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ">") {
|
||||
continue
|
||||
}
|
||||
replyb.WriteString(line)
|
||||
replyb.WriteByte('\n')
|
||||
content, parentEvt, err := parseIssueReplyContent(issueEvt, comments, edited)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(replyb.String())
|
||||
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", issueEvt.ID.Hex(), issueEvt.Relay.URL},
|
||||
nostr.Tag{"e", parentEvt.ID.Hex(), parentEvt.Relay.URL},
|
||||
nostr.Tag{"P", issueEvt.PubKey.Hex()},
|
||||
nostr.Tag{"p", issueEvt.PubKey.Hex()},
|
||||
nostr.Tag{"p", parentEvt.PubKey.Hex()},
|
||||
},
|
||||
Content: content,
|
||||
}
|
||||
@@ -1623,7 +1640,7 @@ func fetchIssueComments(ctx context.Context, repo nip34.Repository, issueID nost
|
||||
for ie := range sys.Pool.FetchMany(ctx, repo.Relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{1111},
|
||||
Tags: nostr.TagMap{
|
||||
"e": []string{issueID.Hex()},
|
||||
"E": []string{issueID.Hex()},
|
||||
},
|
||||
Limit: 500,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-git"}) {
|
||||
@@ -1676,19 +1693,19 @@ func printIssueCommentsThreaded(
|
||||
render = func(parent nostr.ID, depth int) {
|
||||
for _, c := range children[parent] {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
id := shortCommitID(c.ID.Hex(), 6)
|
||||
author := authorPreview(ctx, c.PubKey)
|
||||
created := c.CreatedAt.Time().Format(time.DateTime)
|
||||
|
||||
if withColor {
|
||||
fmt.Fprintln(w, indent+color.CyanString(id), color.HiBlueString(author), color.HiBlackString(created))
|
||||
fmt.Fprintln(w, indent+color.CyanString("["+c.ID.Hex()[0:6]+"]"), color.HiBlueString(author), color.HiBlackString(created))
|
||||
} else {
|
||||
fmt.Fprintln(w, indent+id+" "+author+" "+created)
|
||||
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)
|
||||
}
|
||||
@@ -1747,6 +1764,7 @@ func printGitDiscussionMetadata(
|
||||
}
|
||||
if subject := evt.Tags.Find("subject"); subject != nil && len(subject) >= 2 {
|
||||
fmt.Fprintln(w, label("subject:"), value(subject[1]))
|
||||
fmt.Fprintln(w, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1829,6 +1847,82 @@ 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
|
||||
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 = issue
|
||||
continue
|
||||
}
|
||||
|
||||
if replyb.Len() == 0 && line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "#>") {
|
||||
quoted := strings.TrimSpace(strings.TrimPrefix(line, "#>"))
|
||||
if strings.EqualFold(quoted, "comments:") {
|
||||
inComments = true
|
||||
currentParent = issue
|
||||
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 == issue.ID {
|
||||
return content, issue, 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 != "" {
|
||||
@@ -1896,25 +1990,24 @@ func shortCommitID(commit string, n int) string {
|
||||
}
|
||||
|
||||
func gitIssueReplyEditorTemplate(ctx context.Context, issue nostr.RelayEvent, comments []nostr.RelayEvent) string {
|
||||
const prefix = "> "
|
||||
|
||||
lines := []string{
|
||||
"# write your reply below",
|
||||
"# lines starting with '#', and quoted context lines starting with '> ', are ignored.",
|
||||
"# write your reply here.",
|
||||
"# lines starting with '#' are ignored.",
|
||||
"",
|
||||
}
|
||||
|
||||
appender := &lineAppender{lines, "> "}
|
||||
appender := &lineAppender{lines, "#> "}
|
||||
|
||||
printGitDiscussionMetadata(ctx, appender, issue, "", false)
|
||||
|
||||
for _, line := range strings.Split(issue.Content, "\n") {
|
||||
appender.lines = append(appender.lines, prefix+line)
|
||||
appender.lines = append(appender.lines, "#> "+line)
|
||||
}
|
||||
|
||||
if len(comments) > 0 {
|
||||
appender.lines = append(appender.lines, prefix+"", prefix+"comments:")
|
||||
appender.lines = append(appender.lines, "#> ", "#> comments:")
|
||||
printIssueCommentsThreaded(ctx, appender, comments, issue.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")
|
||||
@@ -1927,7 +2020,7 @@ type lineAppender struct {
|
||||
|
||||
func (l *lineAppender) Write(b []byte) (int, error) {
|
||||
for _, line := range strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
line = strings.TrimRight(line, " ")
|
||||
l.lines = append(l.lines, l.prefix+line)
|
||||
}
|
||||
return len(b), nil
|
||||
|
||||
18
helpers.go
18
helpers.go
@@ -12,6 +12,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -536,12 +537,23 @@ func decodeTagValue(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func editWithDefaultEditor(pattern string, initialContent string) (string, error) {
|
||||
tmp, err := os.CreateTemp("", pattern)
|
||||
func editWithDefaultEditor(filename string, initialContent string, wipe bool) (string, error) {
|
||||
fullpath := filepath.Join(os.TempDir(), filename)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fullpath), 0700); err != nil {
|
||||
return "", fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
|
||||
if wipe {
|
||||
if err := os.Remove(fullpath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("failed to remove temp file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tmp, err := os.OpenFile(fullpath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
if _, err := tmp.WriteString(initialContent); err != nil {
|
||||
tmp.Close()
|
||||
|
||||
Reference in New Issue
Block a user