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 = ""
+ issueDraftTemplate = "\n\n" + issueDraftSentinel + "\n" +
+ "\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", wantTitle: "Bug report", wantBody: ""},
+ {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];