git: issues and patches.

This commit is contained in:
fiatjaf
2026-03-06 18:40:54 -03:00
parent f59c8a670d
commit 5957c08d15

584
git.go
View File

@@ -782,6 +782,286 @@ aside from those, there is also:
return err
},
},
{
Name: "patch",
Usage: "patch-related operations",
ArgsUsage: "[id-prefix]",
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 := fetchGitRepoDiscussionEvents(ctx, repo, []nostr.Kind{1617})
if err != nil {
return err
}
statuses, err := fetchIssueStatus(ctx, repo, patches)
if err != nil {
return err
}
evt, err := findEventByPrefix(patches, prefix)
if err != nil {
return err
}
printGitDiscussionMetadata(evt, statusLabelForEvent(evt.ID, statuses, false))
return showTextWithGitPager(evt.Content)
},
Commands: []*cli.Command{
{
Name: "send",
Usage: "edit and send a patch event (kind 1617)",
Flags: defaultKeyFlags,
Action: func(ctx context.Context, c *cli.Command) error {
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
}
if c.Args().Len() != 1 {
return fmt.Errorf("must specify a commit to send as a patch, 'HEAD^' for the latest")
}
patchData, err := exec.Command("git", "format-patch", "--stdout", "--histogram", c.Args().First()).Output()
if err != nil {
stderr := ""
if ee, ok := err.(*exec.ExitError); ok {
stderr = strings.TrimSpace(string(ee.Stderr))
}
if stderr != "" {
return fmt.Errorf("git format-patch failed: %s", stderr)
}
return fmt.Errorf("git format-patch failed: %w", err)
}
if len(patchData) == 0 {
return fmt.Errorf("git format-patch returned empty output")
}
if len(patchData) > 10*1024 {
return fmt.Errorf("patch too large: %d bytes (limit is 10240 bytes)", len(patchData))
}
content, err := editContentWithDefaultEditor("nak-git-patch-*.patch", string(patchData))
if err != nil {
return err
}
if strings.TrimSpace(content) == "" {
return fmt.Errorf("empty patch content, aborting")
}
if len(content) > 10_000 {
return fmt.Errorf("patch too large: %d bytes (limit is 10000 bytes)", len(content))
}
evt := nostr.Event{
CreatedAt: nostr.Now(),
Kind: 1617,
Tags: nostr.Tags{
nostr.Tag{"a", fmt.Sprintf("30617:%s:%s", repo.Event.PubKey.Hex(), repo.ID)},
nostr.Tag{"p", repo.Event.PubKey.Hex()},
},
Content: content,
}
if repo.EarliestUniqueCommitID != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"r", repo.EarliestUniqueCommitID})
}
if err := kr.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("failed to sign patch event: %w", err)
}
if err := confirmGitEventToBeSent(evt, repo.Relays, "send this patch event"); err != nil {
return err
}
return publishGitEventToRepoRelays(ctx, evt, repo.Relays)
},
},
{
Name: "list",
Usage: "list patches found in repository relays",
Action: func(ctx context.Context, c *cli.Command) error {
repo, err := readGitRepositoryFromConfig()
if err != nil {
return err
}
events, err := fetchGitRepoDiscussionEvents(ctx, repo, []nostr.Kind{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
}
for _, evt := range events {
id := evt.ID.Hex()
status := statusLabelForEvent(evt.ID, statuses, false)
stdout(id[:8], colorizeGitStatus(status))
}
return nil
},
},
{
Name: "apply",
Usage: "apply a patch to current branch",
ArgsUsage: "<id-prefix>",
Action: func(ctx context.Context, c *cli.Command) error {
prefix := strings.TrimSpace(c.Args().First())
if prefix == "" {
return fmt.Errorf("missing patch id prefix")
}
repo, err := readGitRepositoryFromConfig()
if err != nil {
return err
}
patches, err := fetchGitRepoDiscussionEvents(ctx, repo, []nostr.Kind{1617})
if err != nil {
return err
}
evt, err := findEventByPrefix(patches, prefix)
if err != nil {
return err
}
if err := applyPatchContentToCurrentBranch(evt.Content); err != nil {
return err
}
log("applied patch %s\n", color.GreenString(evt.ID.Hex()[:8]))
return nil
},
},
},
},
{
Name: "issue",
Usage: "issue-related operations",
ArgsUsage: "[id-prefix]",
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 := fetchGitRepoDiscussionEvents(ctx, repo, []nostr.Kind{1621})
if err != nil {
return err
}
statuses, err := fetchIssueStatus(ctx, repo, issues)
if err != nil {
return err
}
evt, err := findEventByPrefix(issues, prefix)
if err != nil {
return err
}
printGitDiscussionMetadata(evt, statusLabelForEvent(evt.ID, statuses, true))
return showTextWithGitPager(evt.Content)
},
Commands: []*cli.Command{
{
Name: "create",
Usage: "edit and send an issue event (kind 1621)",
Flags: defaultKeyFlags,
Action: func(ctx context.Context, c *cli.Command) error {
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
}
content, err := editContentWithDefaultEditor("nak-git-issue-*.md", "")
if err != nil {
return err
}
if strings.TrimSpace(content) == "" {
return fmt.Errorf("empty issue content, aborting")
}
evt := nostr.Event{
CreatedAt: nostr.Now(),
Kind: 1621,
Tags: nostr.Tags{
nostr.Tag{"a", fmt.Sprintf("30617:%s:%s", repo.Event.PubKey.Hex(), repo.ID)},
nostr.Tag{"p", repo.Event.PubKey.Hex()},
},
Content: content,
}
if err := kr.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("failed to sign issue event: %w", err)
}
if err := confirmGitEventToBeSent(evt, repo.Relays, "send this issue event"); 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 := fetchGitRepoDiscussionEvents(ctx, repo, []nostr.Kind{1621})
if err != nil {
return err
}
statuses, err := fetchIssueStatus(ctx, repo, events)
if err != nil {
return err
}
if len(events) == 0 {
stdout("no issues found")
return nil
}
for _, evt := range events {
id := evt.ID.Hex()
status := statusLabelForEvent(evt.ID, statuses, true)
stdout(id[:8], colorizeGitStatus(status))
}
return nil
},
},
},
},
{
Name: "status",
Usage: "show repository status and synchronization information",
@@ -811,7 +1091,8 @@ aside from those, there is also:
stdout(" earliest unique commit:", color.CyanString(repo.EarliestUniqueCommitID))
// fetch repository announcement and state from relays
_, _, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
_, _, upToDateRelays, state, err := fetchRepositoryAndState(
ctx, owner, localConfig.Identifier, localConfig.GraspServers)
if err != nil {
// create a local repo object for display purposes
log("failed to fetch repository announcement from relays: %s\n", err)
@@ -947,6 +1228,307 @@ func promptForStringList(
return selected, nil
}
func readGitRepositoryFromConfig() (nip34.Repository, error) {
localConfig, err := readNip34ConfigFile("")
if err != nil {
return nip34.Repository{}, err
}
repo := localConfig.ToRepository()
if len(repo.Relays) == 0 {
return nip34.Repository{}, fmt.Errorf("no relays configured in nip34.json")
}
return repo, nil
}
func editContentWithDefaultEditor(pattern string, initialContent string) (string, error) {
tmp, err := os.CreateTemp("", pattern)
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()
return "", fmt.Errorf("failed to write temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return "", fmt.Errorf("failed to close temp file: %w", err)
}
editor := strings.TrimSpace(os.Getenv("VISUAL"))
if editor == "" {
editor = strings.TrimSpace(os.Getenv("EDITOR"))
}
if editor == "" {
editor = "vi"
}
parts := strings.Fields(editor)
if len(parts) == 0 {
return "", fmt.Errorf("failed to parse editor command '%s'", editor)
}
args := append(parts[1:], tmp.Name())
cmd := exec.Command(parts[0], args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("editor command failed: %w", err)
}
data, err := os.ReadFile(tmp.Name())
if err != nil {
return "", fmt.Errorf("failed to read edited temp file: %w", err)
}
return string(data), nil
}
func confirmGitEventToBeSent(evt nostr.Event, relays []string, question string) error {
pretty, err := json.MarshalIndent(evt, "", " ")
if err != nil {
return fmt.Errorf("failed to encode event for preview: %w", err)
}
stdout(string(pretty))
stdout("relays:", strings.Join(relays, " "))
if !askConfirmation(question + "? ") {
return fmt.Errorf("aborted")
}
return nil
}
func publishGitEventToRepoRelays(ctx context.Context, evt nostr.Event, relays []string) error {
successes := make([]string, 0, len(relays))
for res := range sys.Pool.PublishMany(ctx, relays, evt) {
if res.Error != nil {
log("! error publishing event to %s: %v\n", color.YellowString(res.RelayURL), res.Error)
} else {
log("> published to %s\n", color.GreenString(res.Relay.URL))
successes = append(successes, res.Relay.URL)
}
}
if len(successes) == 0 {
return fmt.Errorf("failed to publish event to any relay")
}
nevent := nip19.EncodeNevent(evt.ID, successes, nostr.ZeroPK)
log("event: %s\n", color.CyanString(nevent))
return nil
}
func fetchGitRepoDiscussionEvents(ctx context.Context, repo nip34.Repository, kinds []nostr.Kind) ([]nostr.Event, error) {
addr := fmt.Sprintf("30617:%s:%s", repo.Event.PubKey.Hex(), repo.ID)
seen := make(map[nostr.ID]nostr.Event, 64)
for ie := range sys.Pool.FetchMany(ctx, repo.Relays, nostr.Filter{
Kinds: kinds,
Tags: nostr.TagMap{
"a": []string{addr},
},
Limit: 500,
}, nostr.SubscriptionOptions{Label: "nak-git"}) {
seen[ie.Event.ID] = ie.Event
}
events := make([]nostr.Event, 0, len(seen))
for _, evt := range seen {
events = append(events, evt)
}
slices.SortFunc(events, func(a, b nostr.Event) int {
if a.CreatedAt > b.CreatedAt {
return -1
}
if a.CreatedAt < b.CreatedAt {
return 1
}
return strings.Compare(a.ID.Hex(), b.ID.Hex())
})
return events, nil
}
func fetchIssueStatus(
ctx context.Context,
repo nip34.Repository,
issues []nostr.Event,
) (map[nostr.ID]nostr.Event, error) {
latest := make(map[nostr.ID]nostr.Event)
maintainers := repo.Maintainers
if !slices.Contains(maintainers, repo.PubKey) {
maintainers = append(maintainers, repo.PubKey)
}
eTags := make([]string, len(issues))
for i, iss := range issues {
eTags[i] = iss.ID.Hex()
}
for ie := range sys.Pool.FetchMany(ctx, repo.Relays, nostr.Filter{
Kinds: []nostr.Kind{1630, 1631, 1632, 1633},
Tags: nostr.TagMap{"e": eTags},
Authors: maintainers,
Limit: 500,
}, nostr.SubscriptionOptions{Label: "nak-git"}) {
targetHex := ""
for _, tag := range ie.Event.Tags {
if len(tag) < 2 || tag[0] != "e" {
continue
}
if targetHex == "" {
targetHex = tag[1]
}
if len(tag) >= 4 && tag[3] == "root" {
targetHex = tag[1]
break
}
}
if targetHex == "" {
continue
}
targetID, err := nostr.IDFromHex(targetHex)
if err != nil {
continue
}
if prev, ok := latest[targetID]; !ok || ie.Event.CreatedAt > prev.CreatedAt {
latest[targetID] = ie.Event
}
}
return latest, nil
}
func findEventByPrefix(events []nostr.Event, prefix string) (nostr.Event, error) {
prefix = strings.ToLower(strings.TrimSpace(prefix))
if prefix == "" {
return nostr.Event{}, fmt.Errorf("missing event id prefix")
}
matchCount := 0
matched := nostr.Event{}
for _, evt := range events {
if strings.HasPrefix(evt.ID.Hex(), prefix) {
matched = evt
matchCount++
}
}
if matchCount == 0 {
return nostr.Event{}, fmt.Errorf("no event found with id prefix '%s'", prefix)
}
if matchCount > 1 {
return nostr.Event{}, fmt.Errorf("id prefix '%s' is ambiguous", prefix)
}
return matched, nil
}
func printGitDiscussionMetadata(evt nostr.Event, status string) {
stdout("id:", evt.ID.Hex())
stdout("kind:", evt.Kind.Num())
stdout("author:", nip19.EncodeNpub(evt.PubKey))
stdout("created:", evt.CreatedAt.Time().Format(time.RFC3339))
stdout("status:", status)
if subject := evt.Tags.Find("subject"); subject != nil && len(subject) >= 2 {
stdout("subject:", subject[1])
}
}
func statusLabelForEvent(id nostr.ID, statuses map[nostr.ID]nostr.Event, isIssue bool) string {
statusEvt, ok := statuses[id]
if !ok {
return "open"
}
switch statusEvt.Kind {
case 1630:
return "open"
case 1631:
if isIssue {
return "resolved"
}
return "applied/merged"
case 1632:
return "closed"
case 1633:
return "draft"
default:
return "open"
}
}
func colorizeGitStatus(status string) string {
switch status {
case "open":
return color.YellowString(status)
case "resolved", "applied/merged":
return color.GreenString(status)
case "closed":
return color.RedString(status)
case "draft":
return color.BlueString(status)
default:
return status
}
}
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 applyPatchContentToCurrentBranch(content string) error {
tmp, err := os.CreateTemp("", "nak-git-apply-*.patch")
if err != nil {
return fmt.Errorf("failed to create temp patch file: %w", err)
}
defer os.Remove(tmp.Name())
if _, err := tmp.WriteString(content); err != nil {
tmp.Close()
return fmt.Errorf("failed to write patch content: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("failed to close patch file: %w", err)
}
cmd := exec.Command("git", "am", "--3way", tmp.Name())
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to apply patch with git am: %w (if needed, run 'git am --abort')", err)
}
return nil
}
func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.RepositoryState, error) {
// read current nip34.json
localConfig, err := readNip34ConfigFile("")