From ec50cd7d586849f2ea382ffbd9850d376186a63f Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 23 Aug 2026 17:38:39 -0600 Subject: [PATCH] cli: compose issues in external editor --- go.mod | 1 + go.sum | 2 + internal/cli/issue_create.go | 51 +++++- internal/cli/issue_editor.go | 112 +++++++++++++ internal/cli/issue_editor_test.go | 257 ++++++++++++++++++++++++++++++ nix/tg.nix | 2 +- 6 files changed, 420 insertions(+), 5 deletions(-) create mode 100644 internal/cli/issue_editor.go create mode 100644 internal/cli/issue_editor_test.go diff --git a/go.mod b/go.mod index 8712bb7..465df3f 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260812204455-68fa937c71be // indirect github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/editor v0.2.0 github.com/charmbracelet/x/exp/slice v0.0.0-20260828171018-3c30eef5e73e // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect diff --git a/go.sum b/go.sum index 15f6a27..b64afc8 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/charmbracelet/ultraviolet v0.0.0-20260812204455-68fa937c71be h1:qEvkJ github.com/charmbracelet/ultraviolet v0.0.0-20260812204455-68fa937c71be/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIRg8gGWwk= +github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/slice v0.0.0-20260828171018-3c30eef5e73e h1:sV2kd+a/AZtBg0xdRUOHRQa8uh0PSLjfL8BfZxe0Qqo= diff --git a/internal/cli/issue_create.go b/internal/cli/issue_create.go index a4b4002..9305ad6 100644 --- a/internal/cli/issue_create.go +++ b/internal/cli/issue_create.go @@ -1,26 +1,62 @@ package cli import ( + "context" + "errors" "fmt" "github.com/alyraffauf/tg/internal/app" "github.com/spf13/cobra" ) -func newIssueCreateCommand(service *app.Service) *cobra.Command { +type issueCreateService interface { + CreateIssue(context.Context, app.Target, string, string) (*app.CreatedRecordResult, error) + TargetFromCWD(context.Context) (app.Target, error) +} + +func newIssueCreateCommand(service issueCreateService) *cobra.Command { var bodyText, bodyFile, repository string command := &cobra.Command{ - Use: "create ", + Use: "create [title]", Short: "Create an issue on a Tangled repository", - Args: cobra.ExactArgs(1), + Long: `Create an issue on a Tangled repository. When title and body are omitted, +tg opens $EDITOR. The edited document's first line is the title, its second line +must be blank, and the remaining text is the required body.`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + if len(args) == 0 { + if cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") { + return fmt.Errorf("title is required when --body or --body-file is used") + } + target, err := resolveIssueCreateTarget(ctx, repository, service) + if err != nil { + return err + } + draft, err := editIssueDraft(ctx, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr()) + if errors.Is(err, errIssueCreationCanceled) { + fmt.Fprintln(cmd.ErrOrStderr(), "Issue creation canceled.") + return nil + } + if err != nil { + return err + } + result, err := service.CreateIssue(ctx, target, draft.Title, draft.Body) + if err != nil { + return fmt.Errorf("%w; draft saved to %s", err, draft.Path) + } + removeIssueDraft(draft.Path, cmd.ErrOrStderr()) + return output(cmd, result, func(result *app.CreatedRecordResult) { + fmt.Fprintf(cmd.OutOrStdout(), "Created issue %s\n", result.URI) + }) + } + body, err := commandBody(bodyText, bodyFile) if err != nil { return err } - target, err := resolveTargetFlag(ctx, repository, service) + target, err := resolveIssueCreateTarget(ctx, repository, service) if err != nil { return err } @@ -38,3 +74,10 @@ func newIssueCreateCommand(service *app.Service) *cobra.Command { command.Flags().StringVarP(&repository, "repo", "R", "", "Target repository as handle/repo") return command } + +func resolveIssueCreateTarget(ctx context.Context, repository string, service issueCreateService) (app.Target, error) { + if repository != "" { + return app.ParseTarget(repository) + } + return service.TargetFromCWD(ctx) +} diff --git a/internal/cli/issue_editor.go b/internal/cli/issue_editor.go new file mode 100644 index 0000000..789d230 --- /dev/null +++ b/internal/cli/issue_editor.go @@ -0,0 +1,112 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/charmbracelet/x/editor" +) + +const ( + issueDraftSentinel = "<!-- tg: everything below this line is ignored -->" + issueDraftTemplate = "\n\n" + issueDraftSentinel + "\n" + + "<!-- Enter a title above, followed by a blank line and a body. -->\n" +) + +var errIssueCreationCanceled = errors.New("issue creation canceled") + +type issueDraft struct { + Title string + Body string + Path string +} + +func editIssueDraft(ctx context.Context, input io.Reader, output, errorOutput io.Writer) (issueDraft, error) { + file, err := os.CreateTemp("", "tg-issue-*.md") + if err != nil { + return issueDraft{}, fmt.Errorf("create issue draft: %w", err) + } + path := file.Name() + if _, err := file.WriteString(issueDraftTemplate); err != nil { + _ = file.Close() + _ = os.Remove(path) + return issueDraft{}, fmt.Errorf("write issue draft: %w", err) + } + if err := file.Close(); err != nil { + _ = os.Remove(path) + return issueDraft{}, fmt.Errorf("close issue draft: %w", err) + } + + command, err := editor.CommandContext(ctx, "tg", path) + if err != nil { + _ = os.Remove(path) + return issueDraft{}, fmt.Errorf("open issue editor: %w", err) + } + command.Stdin = input + command.Stdout = output + command.Stderr = errorOutput + if err := command.Run(); err != nil { + return issueDraft{}, fmt.Errorf("run issue editor: %w; draft saved to %s", err, path) + } + + contents, err := os.ReadFile(path) + if err != nil { + return issueDraft{}, fmt.Errorf("read issue draft: %w; draft saved to %s", err, path) + } + title, body, err := parseIssueDraft(string(contents)) + if errors.Is(err, errIssueCreationCanceled) { + removeIssueDraft(path, errorOutput) + return issueDraft{}, err + } + if err != nil { + return issueDraft{}, fmt.Errorf("parse issue draft: %w; draft saved to %s", err, path) + } + return issueDraft{Title: title, Body: body, Path: path}, nil +} + +func parseIssueDraft(document string) (string, string, error) { + document = strings.ReplaceAll(document, "\r\n", "\n") + lines := strings.Split(document, "\n") + for index, line := range lines { + if line == issueDraftSentinel { + lines = lines[:index] + break + } + } + document = strings.Join(lines, "\n") + if strings.TrimSpace(document) == "" { + return "", "", errIssueCreationCanceled + } + + titleLine, remainder, found := strings.Cut(document, "\n") + if !found { + return "", "", fmt.Errorf("title must be followed by an empty line") + } + blankLine, body, found := strings.Cut(remainder, "\n") + if !found { + blankLine = remainder + body = "" + } + if blankLine != "" { + return "", "", fmt.Errorf("second line must be empty") + } + title := strings.TrimSpace(titleLine) + if title == "" { + return "", "", fmt.Errorf("title must not be empty") + } + body = strings.Trim(body, "\n") + if strings.TrimSpace(body) == "" { + return "", "", fmt.Errorf("body must not be empty") + } + return title, body, nil +} + +func removeIssueDraft(path string, errorOutput io.Writer) { + if err := os.Remove(path); err != nil { + fmt.Fprintf(errorOutput, "warning: remove issue draft %s: %v\n", path, err) + } +} diff --git a/internal/cli/issue_editor_test.go b/internal/cli/issue_editor_test.go new file mode 100644 index 0000000..0e19268 --- /dev/null +++ b/internal/cli/issue_editor_test.go @@ -0,0 +1,257 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/alyraffauf/tg/internal/app" +) + +func TestParseIssueDraft(t *testing.T) { + tests := []struct { + name string + document string + wantTitle string + wantBody string + wantError string + }{ + {name: "title only", document: "Bug report\n", wantError: "body must not be empty"}, + {name: "empty body", document: "Bug report\n\n", wantError: "body must not be empty"}, + {name: "body", document: "Bug report\n\nSteps to reproduce\n\nMore detail\n", wantTitle: "Bug report", wantBody: "Steps to reproduce\n\nMore detail"}, + {name: "indented code body", document: "Bug report\n\n code\n", wantTitle: "Bug report", wantBody: " code"}, + {name: "trailing spaces preserved", document: "Bug report\n\nDetails \n", wantTitle: "Bug report", wantBody: "Details "}, + {name: "CRLF", document: "Bug report\r\n\r\nDetails\r\n", wantTitle: "Bug report", wantBody: "Details"}, + {name: "instructions removed", document: "Bug report\n\nDetails\n" + issueDraftSentinel + "\nignored", wantTitle: "Bug report", wantBody: "Details"}, + {name: "other HTML comment retained", document: "Bug report\n\n<!-- keep this -->", wantTitle: "Bug report", wantBody: "<!-- keep this -->"}, + {name: "untouched template cancels", document: issueDraftTemplate, wantError: errIssueCreationCanceled.Error()}, + {name: "whitespace cancels", document: " \n\n\t", wantError: errIssueCreationCanceled.Error()}, + {name: "missing second line", document: "Bug report", wantError: "title must be followed by an empty line"}, + {name: "nonempty second line", document: "Bug report\nDetails", wantError: "second line must be empty"}, + {name: "empty title with body", document: "\n\nDetails", wantError: "title must not be empty"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + title, body, err := parseIssueDraft(test.document) + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("parseIssueDraft() error = %v, want containing %q", err, test.wantError) + } + return + } + if err != nil { + t.Fatalf("parseIssueDraft() error = %v", err) + } + if title != test.wantTitle || body != test.wantBody { + t.Fatalf("parseIssueDraft() = (%q, %q), want (%q, %q)", title, body, test.wantTitle, test.wantBody) + } + }) + } +} + +func TestIssueCreateBodyWithoutTitleDoesNotOpenEditor(t *testing.T) { + command := newIssueCreateCommand(nil) + command.SetArgs([]string{"--body", "details"}) + command.SetErr(io.Discard) + err := command.Execute() + if err == nil || err.Error() != "title is required when --body or --body-file is used" { + t.Fatalf("Execute() error = %v", err) + } +} + +func TestEditIssueDraftLifecycle(t *testing.T) { + tests := []struct { + name string + document string + exitStatus int + wantTitle string + wantBody string + wantError string + wantSaved bool + }{ + {name: "valid draft", document: "Bug report\n\nDetails\n", wantTitle: "Bug report", wantBody: "Details", wantSaved: true}, + {name: "canceled draft", document: issueDraftTemplate, wantError: errIssueCreationCanceled.Error()}, + {name: "empty body", document: "Bug report\n\n", wantError: "body must not be empty", wantSaved: true}, + {name: "malformed draft", document: "Bug report\nDetails\n", wantError: "second line must be empty", wantSaved: true}, + {name: "failed editor", document: "Unfinished\n\nDraft\n", exitStatus: 23, wantError: "run issue editor", wantSaved: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pathLog := filepath.Join(t.TempDir(), "path") + editorPath := writeIssueEditor(t, pathLog, test.document, test.exitStatus) + t.Setenv("EDITOR", editorPath) + + draft, err := editIssueDraft(context.Background(), nil, io.Discard, io.Discard) + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("editIssueDraft() error = %v, want containing %q", err, test.wantError) + } + } else if err != nil { + t.Fatalf("editIssueDraft() error = %v", err) + } + if draft.Title != test.wantTitle || draft.Body != test.wantBody { + t.Fatalf("editIssueDraft() = %+v, want title %q and body %q", draft, test.wantTitle, test.wantBody) + } + + pathBytes, err := os.ReadFile(pathLog) + if err != nil { + t.Fatal(err) + } + path := string(pathBytes) + _, statError := os.Stat(path) + if test.wantSaved && statError != nil { + t.Fatalf("saved draft %q: %v", path, statError) + } + if !test.wantSaved && !errors.Is(statError, os.ErrNotExist) { + t.Fatalf("canceled draft %q still exists", path) + } + if test.wantSaved { + t.Cleanup(func() { _ = os.Remove(path) }) + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if permissions := info.Mode().Perm(); permissions != 0o600 { + t.Fatalf("draft permissions = %o, want 600", permissions) + } + } + }) + } +} + +func TestIssueCreateRetainsEditedDraftWhenSubmissionFails(t *testing.T) { + pathLog := filepath.Join(t.TempDir(), "path") + t.Setenv("EDITOR", writeIssueEditor(t, pathLog, "Bug report\n\nDetails\n", 0)) + service := &testIssueCreateService{createError: errors.New("network unavailable")} + command := newIssueCreateCommand(service) + command.SetArgs([]string{"--repo", "alice.example/project"}) + command.SetIn(nil) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + + err := command.Execute() + if err == nil || !strings.Contains(err.Error(), "network unavailable; draft saved to") { + t.Fatalf("Execute() error = %v", err) + } + pathBytes, readError := os.ReadFile(pathLog) + if readError != nil { + t.Fatal(readError) + } + path := string(pathBytes) + t.Cleanup(func() { _ = os.Remove(path) }) + if _, statError := os.Stat(path); statError != nil { + t.Fatalf("saved draft %q: %v", path, statError) + } + if service.title != "Bug report" || service.body != "Details" { + t.Fatalf("submission = (%q, %q)", service.title, service.body) + } +} + +func TestIssueCreateRemovesEditedDraftAfterSubmission(t *testing.T) { + pathLog := filepath.Join(t.TempDir(), "path") + t.Setenv("EDITOR", writeIssueEditor(t, pathLog, "Bug report\n\nDetails\n", 0)) + service := &testIssueCreateService{} + command := newIssueCreateCommand(service) + command.SetArgs([]string{"--repo", "alice.example/project"}) + command.SetIn(nil) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + pathBytes, err := os.ReadFile(pathLog) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(string(pathBytes)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("submitted draft still exists: %v", err) + } +} + +func TestIssueCreateExplicitInvocationDoesNotOpenEditor(t *testing.T) { + t.Setenv("EDITOR", filepath.Join(t.TempDir(), "missing-editor")) + service := &testIssueCreateService{} + command := newIssueCreateCommand(service) + command.SetArgs([]string{"Bug report", "--body", "Details", "--repo", "alice.example/project"}) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if service.title != "Bug report" || service.body != "Details" { + t.Fatalf("submission = (%q, %q)", service.title, service.body) + } +} + +func TestIssueCreateExplicitTitleOnlyRemainsValid(t *testing.T) { + t.Setenv("EDITOR", filepath.Join(t.TempDir(), "missing-editor")) + service := &testIssueCreateService{} + command := newIssueCreateCommand(service) + command.SetArgs([]string{"Bug report", "--repo", "alice.example/project"}) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + + if err := command.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if service.title != "Bug report" || service.body != "" { + t.Fatalf("submission = (%q, %q)", service.title, service.body) + } +} + +func TestRemoveIssueDraftWarnsWhenRemovalFails(t *testing.T) { + var errorOutput strings.Builder + path := t.TempDir() + if err := os.WriteFile(filepath.Join(path, "draft"), []byte("content"), 0o600); err != nil { + t.Fatal(err) + } + removeIssueDraft(path, &errorOutput) + if !strings.Contains(errorOutput.String(), "warning: remove issue draft") { + t.Fatalf("warning = %q", errorOutput.String()) + } +} + +type testIssueCreateService struct { + createError error + title string + body string +} + +func (service *testIssueCreateService) CreateIssue(_ context.Context, _ app.Target, title, body string) (*app.CreatedRecordResult, error) { + service.title = title + service.body = body + if service.createError != nil { + return nil, service.createError + } + return &app.CreatedRecordResult{URI: "at://did:plc:owner/sh.tangled.repo.issue/123"}, nil +} + +func (*testIssueCreateService) TargetFromCWD(context.Context) (app.Target, error) { + return app.Target{Handle: "alice.example", Repo: "project"}, nil +} + +func writeIssueEditor(t *testing.T, pathLog, document string, exitStatus int) string { + t.Helper() + script := filepath.Join(t.TempDir(), "editor.sh") + contents := "#!/bin/sh\n" + + "printf '%s' \"$1\" > " + shellQuote(pathLog) + "\n" + + "cat > \"$1\" <<'TG_ISSUE_DRAFT'\n" + document + "TG_ISSUE_DRAFT\n" + + "exit " + fmt.Sprint(exitStatus) + "\n" + if err := os.WriteFile(script, []byte(contents), 0o700); err != nil { + t.Fatal(err) + } + return script +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} diff --git a/nix/tg.nix b/nix/tg.nix index 9f1cdd0..946965d 100644 --- a/nix/tg.nix +++ b/nix/tg.nix @@ -10,7 +10,7 @@ buildGoModule { version = "dev"; src = ../.; proxyVendor = true; - vendorHash = "sha256-MVT3Td1E2/cD+3I4BHo7rLkMdqVsx8UriRNojU7TB1s="; + vendorHash = "sha256-HwdQ7edmO/Dr/9dfYKABBulxrop1bjzC75BIcIdpE00="; subPackages = ["cmd/tg"]; nativeBuildInputs = [installShellFiles]; -- 2.51.2