diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..61ba9a5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @jamesjohnsdev diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..28591cc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "saturday" + time: "03:00" + timezone: "UTC" + cooldown: + default-days: 7 + groups: + go-dependencies: + patterns: + - "*" + open-pull-requests-limit: 3 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "saturday" + time: "03:00" + timezone: "UTC" + cooldown: + default-days: 7 + groups: + github-actions: + patterns: + - "*" + open-pull-requests-limit: 3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7ab9e77 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +permissions: + contents: read + +on: + pull_request: + branches: [main] + +jobs: + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: golangci-lint + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9 + + - name: govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@v1.3.0 ./... + + - name: Go Tidy + run: go mod tidy && git diff --exit-code + + - name: Go Mod Verify + run: go mod verify + + - name: Build + run: go build -o /dev/null ./... + + - name: Test + run: go test -v -count=1 -race -shuffle=on -coverprofile=coverage.txt ./... + + - name: Coverage summary + run: go tool cover -func=coverage.txt >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..c2320a2 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,79 @@ +name: Go fuzz tests + +on: + schedule: + - cron: "0 3 * * 0" # Every Sunday at 03:00 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + discover: + name: Discover fuzz tests + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Find all fuzz tests + id: set-matrix + run: | + matrix=$( + { grep -rl "^func Fuzz" transpiler --include="*_test.go" || true; } | sort | while read -r file; do + pkg_dir=$(dirname "$file") + go_pkg="./${pkg_dir#transpiler}" + grep "^func Fuzz" "$file" | sed 's/func \(Fuzz[^(]*\).*/\1/' | while read -r fn; do + printf '{"function":"%s","go_package":"%s","corpus_path":"%s/testdata/fuzz"}\n' \ + "$fn" "$go_pkg" "$pkg_dir" + done + done | jq -sc '.' + ) + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + + fuzz: + name: ${{ matrix.function }} + needs: discover + if: needs.discover.outputs.matrix != '[]' + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.discover.outputs.matrix) }} + defaults: + run: + working-directory: transpiler + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: transpiler/go.mod + cache: false + - name: Run ${{ matrix.function }} + env: + GO_PKG: ${{ matrix.go_package }} + FUZZ_FN: ${{ matrix.function }} + run: go test "$GO_PKG" "-fuzz=$FUZZ_FN" -fuzztime=5m + - name: Upload fuzz failure seed corpus + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fuzz-corpus-${{ matrix.function }} + path: ${{ matrix.corpus_path }}/${{ matrix.function }} + - name: Output troubleshooting message + if: failure() + env: + FUZZ_FN: ${{ matrix.function }} + RUN_ID: ${{ github.run_id }} + SHA: ${{ github.sha }} + shell: bash + run: | + echo -e "Fuzz test $FUZZ_FN failed on commit $SHA. To troubleshoot locally, use the GitHub CLI to download the seed corpus with\n\ngh run download $RUN_ID -n fuzz-corpus-$FUZZ_FN\n" diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 0000000..1d8ce96 --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,23 @@ +name: Semgrep + +on: + pull_request: + branches: [main] + schedule: + - cron: "0 4 * * 0" # Every Sunday at 04:00 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + semgrep: + name: Semgrep scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Run Semgrep + run: pipx run semgrep scan --config=p/golang --config=p/secrets --error diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml new file mode 100644 index 0000000..a733fa5 --- /dev/null +++ b/.github/workflows/workflow-lint.yml @@ -0,0 +1,44 @@ +name: Workflow Lint + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/**" + push: + branches: [main] + paths: + - ".github/workflows/**" + +permissions: + contents: read + +jobs: + zizmor: + name: zizmor + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Run zizmor + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + with: + advanced-security: false + inputs: .github/workflows/ + + actionlint: + name: actionlint + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2 diff --git a/README.md b/README.md index 27bc337..d955fbc 100644 --- a/README.md +++ b/README.md @@ -11,23 +11,26 @@ also for AI augmented workflows, where agents can have a source for development. ### Core -- `issue init` - creates a new `.issues` directory in the current directory. -- `issue list` - lists all issues in the current directory. -- `issue create` - creates a new issue in the current directory. -- `issue sync` - syncs all issues in the current directory using the GitHub CLI. -- `issue help`` - shows help for the `issue` command. +- `issues init` - creates a new `.issues` directory in the current directory. +- `issues list` - lists all issues in the current directory. +- `issues create ` - creates a new issue in the current directory. +- `issues create -e` - opens a blank issue in the editor without requiring a title upfront (discarded if saved with no title). +- `issues view <number>` - opens an issue by number in the default editor. +- `issues close <number>` - marks an issue as closed. +- `issues sync` - syncs all issues in the current directory using the GitHub CLI. +- `issues help` - shows help for the `issues` command. ### Agentic These are planned commnads. The idea is that you will define a preferred agent in a config file. -- `issue agent` - sends the current issue to the agent. -- `issue agent list` - sends the list of issues to the agent. +- `issues agent` - sends the current issue to the agent. +- `issues agent list` - sends the list of issues to the agent. There will be a agent skill created as as well down the line. ## How it works -The `.issue` directory is stored in the project root. It contains a `config.yaml` file. It contains an `issues` directory, which contains current local versions of the issues. +The `.issues` directory is stored in the project root. It contains a `config.yaml` file. It contains an `issues` directory, which contains current local versions of the issues. There is a `.remote_issues` directory, which contains the remote versions of the issues. During synchronisation, the local versions are updated to match the remote versions. diff --git a/cmd/close.go b/cmd/close.go new file mode 100644 index 0000000..f08491b --- /dev/null +++ b/cmd/close.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/fatih/color" + "github.com/jamesjohnsdev/issues/internal/issue" + "github.com/spf13/cobra" +) + +var closeCmd = &cobra.Command{ + Use: "close <number>", + Short: "Mark an issue as closed", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + root, err := issuesRoot() + if err != nil { + return err + } + + iss, err := findLocalByID(root, args[0]) + if err != nil { + return err + } + + if iss.State == "closed" { + fmt.Printf("Issue %s is already closed.\n", args[0]) + return nil + } + + iss.State = "closed" + + newPath := filepath.Join(closedDir(root), filepath.Base(iss.Path)) + if err := os.MkdirAll(closedDir(root), 0755); err != nil { + return err + } + if err := issue.Write(newPath, iss); err != nil { + return err + } + if err := os.Remove(iss.Path); err != nil { + return err + } + + fmt.Printf("%s %s: %s\n", color.GreenString("Closed"), args[0], iss.Title) + return nil + }, +} diff --git a/cmd/close_test.go b/cmd/close_test.go new file mode 100644 index 0000000..7596efd --- /dev/null +++ b/cmd/close_test.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jamesjohnsdev/issues/internal/issue" +) + +func TestClose(t *testing.T) { + tests := []struct { + name string + arg string + fixtures []issueFixture + wantErr bool + wantInClosed string // filename that should exist in closed/ after the command + wantGoneFromOpen string // filename that should be gone from open/ after the command + }{ + { + name: "closes open GitHub issue by number", + arg: "1", + fixtures: []issueFixture{ + {"1-first-issue.md", issue.Issue{Number: 1, Title: "First issue", State: "open"}}, + }, + wantInClosed: "1-first-issue.md", + wantGoneFromOpen: "1-first-issue.md", + }, + { + name: "closes open T-issue by T-id", + arg: "T1", + fixtures: []issueFixture{ + {"T1-local-draft.md", issue.Issue{Title: "Local draft", State: "open"}}, + }, + wantInClosed: "T1-local-draft.md", + wantGoneFromOpen: "T1-local-draft.md", + }, + { + name: "case-insensitive T-id", + arg: "t1", + fixtures: []issueFixture{ + {"T1-local-draft.md", issue.Issue{Title: "Local draft", State: "open"}}, + }, + wantInClosed: "T1-local-draft.md", + wantGoneFromOpen: "T1-local-draft.md", + }, + { + name: "already closed issue is a no-op", + arg: "1", + fixtures: []issueFixture{ + {"1-done.md", issue.Issue{Number: 1, Title: "Done", State: "closed"}}, + }, + }, + { + name: "unknown number returns error", + arg: "99", + fixtures: nil, + wantErr: true, + }, + { + name: "unknown T-id returns error", + arg: "T99", + fixtures: nil, + wantErr: true, + }, + { + name: "invalid id returns error", + arg: "abc", + fixtures: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := makeProjectDir(t, tt.fixtures) + chdirTo(t, parent) + issuesDir := filepath.Join(parent, issuesDirName) + + err := closeCmd.RunE(closeCmd, []string{tt.arg}) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.wantInClosed != "" { + closedPath := filepath.Join(issuesDir, "closed", tt.wantInClosed) + if _, err := os.Stat(closedPath); os.IsNotExist(err) { + t.Errorf("expected %s in closed/, but it does not exist", tt.wantInClosed) + } else { + iss, err := issue.Parse(closedPath) + if err != nil { + t.Fatalf("parsing closed file: %v", err) + } + if iss.State != "closed" { + t.Errorf("State = %q, want %q", iss.State, "closed") + } + } + } + + if tt.wantGoneFromOpen != "" { + openPath := filepath.Join(issuesDir, "open", tt.wantGoneFromOpen) + if _, err := os.Stat(openPath); !os.IsNotExist(err) { + t.Errorf("expected %s to be gone from open/, but it still exists", tt.wantGoneFromOpen) + } + } + }) + } +} diff --git a/cmd/create.go b/cmd/create.go index 1a3dd03..3de9d40 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -5,23 +5,30 @@ import ( "os" "os/exec" "path/filepath" + "strings" "github.com/fatih/color" "github.com/jamesjohnsdev/issues/internal/issue" "github.com/spf13/cobra" ) +var createEditorFlag bool + var createCmd = &cobra.Command{ Use: "create <title>", Short: "Create a new local issue", - Args: cobra.ExactArgs(1), + Args: func(cmd *cobra.Command, args []string) error { + if createEditorFlag { + return cobra.NoArgs(cmd, args) + } + return cobra.ExactArgs(1)(cmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { root, err := issuesRoot() if err != nil { return err } - // Find highest existing T-number existing, err := loadAllLocal(root) if err != nil { return err @@ -30,16 +37,25 @@ var createCmd = &cobra.Command{ for _, e := range existing { if e.Number == 0 { var n int - fmt.Sscanf(idFromPath(e.Path), "T%d", &n) + if _, err := fmt.Sscanf(idFromPath(e.Path), "T%d", &n); err != nil { + return err + } if n > maxT { maxT = n } } } - title := args[0] + var title, filename string + if createEditorFlag { + title = "" + filename = fmt.Sprintf("T%d-new-issue.md", maxT+1) + } else { + title = args[0] + filename = fmt.Sprintf("T%d-%s.md", maxT+1, issue.Slug(title)) + } + iss := &issue.Issue{Title: title, State: "open"} - filename := fmt.Sprintf("T%d-%s.md", maxT+1, issue.Slug(title)) path := filepath.Join(openDir(root), filename) if err := issue.Write(path, iss); err != nil { @@ -50,15 +66,33 @@ var createCmd = &cobra.Command{ if editor == "" { editor = os.Getenv("EDITOR") } + if createEditorFlag && editor == "" { + _ = os.Remove(path) + return fmt.Errorf("no editor set: define $VISUAL or $EDITOR") + } if editor != "" { - c := exec.Command(editor, path) + parts := strings.Fields(editor) + c := exec.Command(parts[0], append(parts[1:], path)...) c.Stdin = os.Stdin c.Stdout = os.Stdout c.Stderr = os.Stderr _ = c.Run() } + if createEditorFlag { + saved, err := issue.Parse(path) + if err != nil || saved.Title == "" { + _ = os.Remove(path) + fmt.Println(color.YellowString("Aborted.") + " No title set, issue discarded.") + return nil + } + } + fmt.Printf("%s %s\n", color.GreenString("Created"), path) return nil }, } + +func init() { + createCmd.Flags().BoolVarP(&createEditorFlag, "editor", "e", false, "open a new blank issue directly in the editor") +} diff --git a/cmd/create_test.go b/cmd/create_test.go new file mode 100644 index 0000000..8570824 --- /dev/null +++ b/cmd/create_test.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCreateEditorFlag(t *testing.T) { + t.Run("no editor set returns error", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + + orig := createEditorFlag + t.Cleanup(func() { createEditorFlag = orig }) + createEditorFlag = true + + err := createCmd.RunE(createCmd, nil) + if err == nil { + t.Error("expected error when no editor is set, got nil") + } + }) + + t.Run("no-op editor deletes file with empty title", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "true") + + orig := createEditorFlag + t.Cleanup(func() { createEditorFlag = orig }) + createEditorFlag = true + + _ = captureStdout(t, func() { + if err := createCmd.RunE(createCmd, nil); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + entries, err := os.ReadDir(filepath.Join(parent, issuesDirName, "open")) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if filepath.Ext(e.Name()) == ".md" { + t.Errorf("expected no .md files after aborting, found %s", e.Name()) + } + } + }) + + t.Run("editor that sets a title keeps the file", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + + script := filepath.Join(t.TempDir(), "editor.sh") + if err := os.WriteFile(script, []byte( + "#!/bin/sh\ncat > \"$1\" <<'EOF'\n---\ntitle: My New Issue\nstate: open\n---\nEOF\n", + ), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", script) + + orig := createEditorFlag + t.Cleanup(func() { createEditorFlag = orig }) + createEditorFlag = true + + _ = captureStdout(t, func() { + if err := createCmd.RunE(createCmd, nil); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + entries, err := os.ReadDir(filepath.Join(parent, issuesDirName, "open")) + if err != nil { + t.Fatal(err) + } + var mdFiles []string + for _, e := range entries { + if filepath.Ext(e.Name()) == ".md" { + mdFiles = append(mdFiles, e.Name()) + } + } + if len(mdFiles) != 1 { + t.Errorf("expected 1 .md file, got %d: %v", len(mdFiles), mdFiles) + } + }) +} diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..cc12e47 --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/fatih/color" + "github.com/jamesjohnsdev/issues/internal/gh" + "github.com/spf13/cobra" +) + +var deleteCmd = &cobra.Command{ + Use: "delete <id>", + Short: "Delete an issue locally (and optionally from GitHub)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + root, err := issuesRoot() + if err != nil { + return err + } + + iss, err := findLocalByID(root, args[0]) + if err != nil { + return err + } + + if iss.Number != 0 { + fmt.Printf("Issue #%d (%s) is synced to GitHub.\n", iss.Number, iss.Title) + fmt.Print("Delete from GitHub as well? [y/N] ") + + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + + if answer == "y" || answer == "yes" { + if err := gh.Delete(iss.Number); err != nil { + return err + } + fmt.Printf("%s #%d from GitHub\n", color.RedString("Deleted"), iss.Number) + } + + // Clean up originals snapshot if present + origPath := filepath.Join(originalsDir(root), fmt.Sprintf("%d.md", iss.Number)) + _ = os.Remove(origPath) + } + + if err := os.Remove(iss.Path); err != nil { + return err + } + + fmt.Printf("%s %s: %s\n", color.RedString("Deleted"), args[0], iss.Title) + return nil + }, +} diff --git a/cmd/fuzz_test.go b/cmd/fuzz_test.go new file mode 100644 index 0000000..293d238 --- /dev/null +++ b/cmd/fuzz_test.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jamesjohnsdev/issues/internal/issue" +) + +func FuzzFindLocalByID(f *testing.F) { + f.Add("1") + f.Add("10") + f.Add("T1") + f.Add("t1") + f.Add("T") + f.Add("") + f.Add("abc") + f.Add("0") + f.Add("99999") + f.Add("T99999") + + // Build a shared root once; all fuzz iterations reuse it. + root, err := os.MkdirTemp("", "fuzz-issues-*") + if err != nil { + f.Fatal(err) + } + f.Cleanup(func() { + if err := os.RemoveAll(root); err != nil { + f.Fatal(err) + } + }) + + for _, dir := range []string{ + filepath.Join(root, "open"), + filepath.Join(root, "closed"), + } { + if err := os.MkdirAll(dir, 0755); err != nil { + f.Fatal(err) + } + } + + fixtures := []struct { + filename string + iss issue.Issue + }{ + {"1-issue.md", issue.Issue{Number: 1, Title: "Issue one", State: "open"}}, + {"10-tenth.md", issue.Issue{Number: 10, Title: "Tenth issue", State: "open"}}, + {"2-closed.md", issue.Issue{Number: 2, Title: "Closed issue", State: "closed"}}, + {"T1-draft.md", issue.Issue{Title: "Local draft", State: "open"}}, + } + for _, fix := range fixtures { + dir := filepath.Join(root, "open") + if fix.iss.State == "closed" { + dir = filepath.Join(root, "closed") + } + iss := fix.iss + if err := issue.Write(filepath.Join(dir, fix.filename), &iss); err != nil { + f.Fatal(err) + } + } + + f.Fuzz(func(t *testing.T, id string) { + // Must never panic regardless of input. + iss, err := findLocalByID(root, id) + if err != nil { + return + } + // On success, the returned issue must have a non-empty path. + if iss.Path == "" { + t.Errorf("findLocalByID(%q) returned issue with empty Path", id) + } + }) +} diff --git a/cmd/list.go b/cmd/list.go index 133df5d..f605f88 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -2,6 +2,8 @@ package cmd import ( "fmt" + "sort" + "strings" "github.com/fatih/color" "github.com/jamesjohnsdev/issues/internal/issue" @@ -28,22 +30,75 @@ var listCmd = &cobra.Command{ filtered = append(filtered, iss) } } + sort.Slice(filtered, func(i, j int) bool { + ni, nj := filtered[i].Number, filtered[j].Number + // GitHub issues (Number > 0) sort before local T-issues (Number == 0) + if (ni == 0) != (nj == 0) { + return ni != 0 + } + if ni != 0 { + return ni < nj + } + // Both local: compare T-numbers + var ti, tj int + if _, err := fmt.Sscanf(idFromPath(filtered[i].Path), "T%d", &ti); err != nil { + return false + } + if _, err := fmt.Sscanf(idFromPath(filtered[j].Path), "T%d", &tj); err != nil { + return false + } + return ti < tj + }) + if len(filtered) == 0 { - fmt.Println("No local issues. Run `issue pull` to fetch from GitHub.") + fmt.Println("No local issues. Run `issues pull` to fetch from GitHub.") return nil } - idColor := color.New(color.FgCyan) - openColor := color.New(color.FgGreen) - closedColor := color.New(color.FgHiBlack) + + bold := color.New(color.Bold).SprintfFunc() + idColor := color.New(color.FgCyan).SprintfFunc() + localIDColor := color.New(color.FgYellow).SprintfFunc() + openColor := color.New(color.FgGreen).SprintfFunc() + closedColor := color.New(color.FgHiBlack).SprintfFunc() + + // Calculate column widths from plain strings, before any color codes are applied + idW, stateW := len("ID"), len("State") for _, iss := range filtered { - id := idColor.Sprintf("%-8s", idFromPath(iss.Path)) + if n := len(idFromPath(iss.Path)); n > idW { + idW = n + } + if n := len(iss.State); n > stateW { + stateW = n + } + } + + const pad = " " + + fmt.Println() + fmt.Printf("%s%s %s %s\n", pad, + bold("%-*s", idW, "ID"), + bold("%-*s", stateW, "State"), + bold("Title"), + ) + fmt.Printf("%s%s %s %s\n", pad, + strings.Repeat("─", idW), + strings.Repeat("─", stateW), + strings.Repeat("─", 33), + ) + for _, iss := range filtered { + var id string + if iss.Number == 0 { + id = localIDColor("%-*s", idW, idFromPath(iss.Path)) + } else { + id = idColor("%-*s", idW, idFromPath(iss.Path)) + } var state string if iss.State == "open" { - state = openColor.Sprintf("%-8s", "["+iss.State+"]") + state = openColor("%-*s", stateW, iss.State) } else { - state = closedColor.Sprintf("%-8s", "["+iss.State+"]") + state = closedColor("%-*s", stateW, iss.State) } - fmt.Printf("%s %s %s\n", id, state, iss.Title) + fmt.Printf("%s%s %s %s\n", pad, id, state, iss.Title) } return nil }, diff --git a/cmd/list_test.go b/cmd/list_test.go new file mode 100644 index 0000000..4006a94 --- /dev/null +++ b/cmd/list_test.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/jamesjohnsdev/issues/internal/issue" +) + +func TestList(t *testing.T) { + t.Run("default shows only open issues", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-open.md", issue.Issue{Number: 1, Title: "Open issue", State: "open"}}, + {"2-closed.md", issue.Issue{Number: 2, Title: "Closed issue", State: "closed"}}, + }) + chdirTo(t, parent) + t.Cleanup(func() { listAll = false; listClosed = false }) + + out := captureStdout(t, func() { + if err := listCmd.RunE(listCmd, nil); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, "Open issue") { + t.Error("expected output to contain open issue title") + } + if strings.Contains(out, "Closed issue") { + t.Error("expected output to not contain closed issue title") + } + }) + + t.Run("closed flag shows only closed issues", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-open.md", issue.Issue{Number: 1, Title: "Open issue", State: "open"}}, + {"2-closed.md", issue.Issue{Number: 2, Title: "Closed issue", State: "closed"}}, + }) + chdirTo(t, parent) + listClosed = true + t.Cleanup(func() { listAll = false; listClosed = false }) + + out := captureStdout(t, func() { + if err := listCmd.RunE(listCmd, nil); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + if strings.Contains(out, "Open issue") { + t.Error("expected output to not contain open issue title") + } + if !strings.Contains(out, "Closed issue") { + t.Error("expected output to contain closed issue title") + } + }) + + t.Run("all flag shows both open and closed", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-open.md", issue.Issue{Number: 1, Title: "Open issue", State: "open"}}, + {"2-closed.md", issue.Issue{Number: 2, Title: "Closed issue", State: "closed"}}, + }) + chdirTo(t, parent) + listAll = true + t.Cleanup(func() { listAll = false; listClosed = false }) + + out := captureStdout(t, func() { + if err := listCmd.RunE(listCmd, nil); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, "Open issue") { + t.Error("expected output to contain open issue title") + } + if !strings.Contains(out, "Closed issue") { + t.Error("expected output to contain closed issue title") + } + }) + + t.Run("no matching issues prints no-issues message", func(t *testing.T) { + parent := makeProjectDir(t, nil) + chdirTo(t, parent) + t.Cleanup(func() { listAll = false; listClosed = false }) + + out := captureStdout(t, func() { + if err := listCmd.RunE(listCmd, nil); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, "No local issues") { + t.Errorf("expected no-issues message, got: %q", out) + } + }) + + t.Run("issues sorted numerically not lexicographically", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-first.md", issue.Issue{Number: 1, Title: "First", State: "open"}}, + {"2-second.md", issue.Issue{Number: 2, Title: "Second", State: "open"}}, + {"10-tenth.md", issue.Issue{Number: 10, Title: "Tenth", State: "open"}}, + }) + chdirTo(t, parent) + t.Cleanup(func() { listAll = false; listClosed = false }) + + out := captureStdout(t, func() { + if err := listCmd.RunE(listCmd, nil); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + posFirst := strings.Index(out, "First") + posSecond := strings.Index(out, "Second") + posTenth := strings.Index(out, "Tenth") + + if posFirst < 0 || posSecond < 0 || posTenth < 0 { + t.Fatalf("not all issues in output: %q", out) + } + if posFirst >= posSecond || posSecond >= posTenth { + t.Errorf("wrong order: First@%d Second@%d Tenth@%d — want First < Second < Tenth", + posFirst, posSecond, posTenth) + } + }) + + t.Run("T-issues appear after GitHub issues", func(t *testing.T) { + parent := makeProjectDir(t, []issueFixture{ + {"1-github.md", issue.Issue{Number: 1, Title: "GitHub issue", State: "open"}}, + {"T1-local.md", issue.Issue{Title: "Local draft", State: "open"}}, + }) + chdirTo(t, parent) + t.Cleanup(func() { listAll = false; listClosed = false }) + + out := captureStdout(t, func() { + if err := listCmd.RunE(listCmd, nil); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + posGH := strings.Index(out, "GitHub issue") + posLocal := strings.Index(out, "Local draft") + + if posGH < 0 || posLocal < 0 { + t.Fatalf("not all issues in output: %q", out) + } + if posGH > posLocal { + t.Error("expected GitHub issue to appear before local T-issue") + } + }) +} diff --git a/cmd/pull.go b/cmd/pull.go index fc0252c..f3afd6c 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -59,7 +59,9 @@ func pullOne(root string, iss *issue.Issue) error { // If the issue exists locally in a different location (e.g. state changed), remove the old file existing, _ := findLocalByNumber(root, iss.Number) if existing != nil && existing.Path != destPath { - os.Remove(existing.Path) + if err := os.Remove(existing.Path); err != nil { + return fmt.Errorf("removing old local issue: %w", err) + } } if err := issue.Write(destPath, iss); err != nil { diff --git a/cmd/push.go b/cmd/push.go index 5416b72..e0fc42c 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -23,11 +23,7 @@ var pushCmd = &cobra.Command{ } if len(args) == 1 { - var number int - if _, err := fmt.Sscanf(args[0], "%d", &number); err != nil { - return fmt.Errorf("invalid issue number: %s", args[0]) - } - iss, err := findLocalByNumber(root, number) + iss, err := findLocalByID(root, args[0]) if err != nil { return err } @@ -60,7 +56,9 @@ var pushCmd = &cobra.Command{ } } if pushed == 0 { - color.New(color.FgHiBlack).Println("Nothing to push.") + if _, err := color.New(color.FgHiBlack).Println("Nothing to push."); err != nil { + return err + } } else { fmt.Printf("%s %d issue(s)\n", color.GreenString("Pushed"), pushed) } diff --git a/cmd/root.go b/cmd/root.go index c4df505..6e79d4b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,7 +9,7 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "issue", + Use: "issues", Short: "Local GitHub issue management", } @@ -61,4 +61,7 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e rootCmd.AddCommand(syncCmd) rootCmd.AddCommand(pushCmd) rootCmd.AddCommand(pullCmd) + rootCmd.AddCommand(viewCmd) + rootCmd.AddCommand(closeCmd) + rootCmd.AddCommand(deleteCmd) } diff --git a/cmd/sync.go b/cmd/sync.go index 1ebb3ce..98693e9 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -68,7 +68,7 @@ var syncCmd = &cobra.Command{ fmt.Print("\nContinue? [y/N] ") line, _ := bufio.NewReader(os.Stdin).ReadString('\n') if strings.ToLower(strings.TrimSpace(line)) != "y" { - fmt.Println(color.YellowString("Aborted.") + " Use `issue push` to send your local changes to GitHub first.") + fmt.Println(color.YellowString("Aborted.") + " Use `issues push` to send your local changes to GitHub first.") return nil } fmt.Println() diff --git a/cmd/util.go b/cmd/util.go index 51bd3d3..49b6608 100644 --- a/cmd/util.go +++ b/cmd/util.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "github.com/jamesjohnsdev/issues/internal/issue" @@ -19,7 +20,7 @@ func issuesRoot() (string, error) { } path := filepath.Join(cwd, issuesDirName) if _, err := os.Stat(path); os.IsNotExist(err) { - return "", errors.New("no .issues directory — run `issue init` first") + return "", errors.New("no .issues directory — run `issues init` first") } return path, nil } @@ -72,6 +73,36 @@ func findLocalByNumber(root string, number int) (*issue.Issue, error) { return nil, fmt.Errorf("issue #%d not found locally", number) } +// findLocalByID accepts either a plain integer ("42") or a T-prefixed local ID ("T1"). +func findLocalByID(root, id string) (*issue.Issue, error) { + issues, err := loadAllLocal(root) + if err != nil { + return nil, err + } + + // T-prefixed local issue + if strings.HasPrefix(strings.ToUpper(id), "T") { + for _, iss := range issues { + if strings.EqualFold(idFromPath(iss.Path), id) { + return iss, nil + } + } + return nil, fmt.Errorf("issue %s not found locally", id) + } + + // GitHub issue number + number, err := strconv.Atoi(id) + if err != nil { + return nil, fmt.Errorf("invalid issue id: %s", id) + } + for _, iss := range issues { + if iss.Number == number { + return iss, nil + } + } + return nil, fmt.Errorf("issue #%d not found locally", number) +} + // idFromPath extracts the ID prefix ("T1" or "42") from a filename like "T1-my-issue.md". func idFromPath(path string) string { base := strings.TrimSuffix(filepath.Base(path), ".md") diff --git a/cmd/util_test.go b/cmd/util_test.go new file mode 100644 index 0000000..6648edb --- /dev/null +++ b/cmd/util_test.go @@ -0,0 +1,295 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/jamesjohnsdev/issues/internal/issue" +) + +type issueFixture struct { + filename string + iss issue.Issue +} + +// makeIssuesRoot creates a temp .issues root with open/ and closed/ dirs +// populated from the given fixtures. +func makeIssuesRoot(t *testing.T, fixtures []issueFixture) string { + t.Helper() + root := t.TempDir() + for _, dir := range []string{ + filepath.Join(root, "open"), + filepath.Join(root, "closed"), + } { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + for _, f := range fixtures { + dir := filepath.Join(root, "open") + if f.iss.State == "closed" { + dir = filepath.Join(root, "closed") + } + iss := f.iss + if err := issue.Write(filepath.Join(dir, f.filename), &iss); err != nil { + t.Fatalf("writing fixture %s: %v", f.filename, err) + } + } + return root +} + +// makeProjectDir creates a temp project root containing a .issues dir with +// open/ and closed/ subdirs populated from fixtures. Returns the project root +// (suitable for os.Chdir so that issuesRoot() can find .issues). +func makeProjectDir(t *testing.T, fixtures []issueFixture) string { + t.Helper() + parent := t.TempDir() + issuesDir := filepath.Join(parent, issuesDirName) + for _, dir := range []string{ + filepath.Join(issuesDir, "open"), + filepath.Join(issuesDir, "closed"), + } { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + for _, f := range fixtures { + dir := filepath.Join(issuesDir, "open") + if f.iss.State == "closed" { + dir = filepath.Join(issuesDir, "closed") + } + iss := f.iss + if err := issue.Write(filepath.Join(dir, f.filename), &iss); err != nil { + t.Fatalf("writing fixture %s: %v", f.filename, err) + } + } + return parent +} + +// chdirTo changes the working directory to dir and restores the original on +// test cleanup. +func chdirTo(t *testing.T, dir string) { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) +} + +// captureStdout runs fn and returns everything written to os.Stdout during its +// execution. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stdout + os.Stdout = w + fn() + if err := w.Close(); err != nil { + t.Fatal(err) + } + os.Stdout = old + var buf bytes.Buffer + if _, err := buf.ReadFrom(r); err != nil { + t.Fatal(err) + } + return buf.String() +} + +func TestIDFromPath(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"/some/dir/1-hello-world.md", "1"}, + {"/some/dir/T1-local-issue.md", "T1"}, + {"/some/dir/10-double-digit.md", "10"}, + {"42-bare-filename.md", "42"}, + {"/some/dir/T10-local-double.md", "T10"}, + {"/some/dir/nodash.md", "nodash"}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := idFromPath(tt.path) + if got != tt.want { + t.Errorf("idFromPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestLoadAllLocal(t *testing.T) { + t.Run("empty dirs returns no issues", func(t *testing.T) { + root := makeIssuesRoot(t, nil) + issues, err := loadAllLocal(root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(issues) != 0 { + t.Errorf("got %d issues, want 0", len(issues)) + } + }) + + t.Run("loads open issues", func(t *testing.T) { + root := makeIssuesRoot(t, []issueFixture{ + {"1-open-issue.md", issue.Issue{Number: 1, Title: "Open issue", State: "open"}}, + }) + issues, err := loadAllLocal(root) + if err != nil { + t.Fatal(err) + } + if len(issues) != 1 { + t.Fatalf("got %d issues, want 1", len(issues)) + } + if issues[0].Title != "Open issue" { + t.Errorf("Title: got %q, want %q", issues[0].Title, "Open issue") + } + }) + + t.Run("loads closed issues", func(t *testing.T) { + root := makeIssuesRoot(t, []issueFixture{ + {"1-closed-issue.md", issue.Issue{Number: 1, Title: "Closed issue", State: "closed"}}, + }) + issues, err := loadAllLocal(root) + if err != nil { + t.Fatal(err) + } + if len(issues) != 1 { + t.Fatalf("got %d issues, want 1", len(issues)) + } + if issues[0].State != "closed" { + t.Errorf("State: got %q, want %q", issues[0].State, "closed") + } + }) + + t.Run("loads both open and closed", func(t *testing.T) { + root := makeIssuesRoot(t, []issueFixture{ + {"1-open.md", issue.Issue{Number: 1, Title: "Open", State: "open"}}, + {"2-closed.md", issue.Issue{Number: 2, Title: "Closed", State: "closed"}}, + }) + issues, err := loadAllLocal(root) + if err != nil { + t.Fatal(err) + } + if len(issues) != 2 { + t.Errorf("got %d issues, want 2", len(issues)) + } + }) + + t.Run("skips non-md files", func(t *testing.T) { + root := makeIssuesRoot(t, []issueFixture{ + {"1-issue.md", issue.Issue{Number: 1, Title: "Issue", State: "open"}}, + }) + if err := os.WriteFile(filepath.Join(root, "open", "readme.txt"), []byte("ignored"), 0644); err != nil { + t.Fatal(err) + } + issues, err := loadAllLocal(root) + if err != nil { + t.Fatal(err) + } + if len(issues) != 1 { + t.Errorf("got %d issues, want 1", len(issues)) + } + }) + + t.Run("skips directories inside open", func(t *testing.T) { + root := makeIssuesRoot(t, nil) + if err := os.MkdirAll(filepath.Join(root, "open", "subdir.md"), 0755); err != nil { + t.Fatal(err) + } + issues, err := loadAllLocal(root) + if err != nil { + t.Fatal(err) + } + if len(issues) != 0 { + t.Errorf("got %d issues, want 0", len(issues)) + } + }) + + t.Run("local T-issues have Number zero", func(t *testing.T) { + root := makeIssuesRoot(t, []issueFixture{ + {"T1-local-draft.md", issue.Issue{Title: "Local draft", State: "open"}}, + }) + issues, err := loadAllLocal(root) + if err != nil { + t.Fatal(err) + } + if len(issues) != 1 { + t.Fatalf("got %d issues, want 1", len(issues)) + } + if issues[0].Number != 0 { + t.Errorf("Number: got %d, want 0", issues[0].Number) + } + }) + + t.Run("sets Path on each issue", func(t *testing.T) { + root := makeIssuesRoot(t, []issueFixture{ + {"1-issue.md", issue.Issue{Number: 1, Title: "Issue", State: "open"}}, + }) + issues, err := loadAllLocal(root) + if err != nil { + t.Fatal(err) + } + if len(issues) != 1 { + t.Fatalf("got %d issues, want 1", len(issues)) + } + if issues[0].Path == "" { + t.Error("Path should not be empty") + } + }) +} + +func TestFindLocalByID(t *testing.T) { + root := makeIssuesRoot(t, []issueFixture{ + {"1-first-issue.md", issue.Issue{Number: 1, Title: "First issue", State: "open"}}, + {"2-second-issue.md", issue.Issue{Number: 2, Title: "Second issue", State: "open"}}, + {"10-tenth-issue.md", issue.Issue{Number: 10, Title: "Tenth issue", State: "open"}}, + {"3-closed-issue.md", issue.Issue{Number: 3, Title: "Closed issue", State: "closed"}}, + {"T1-local-draft.md", issue.Issue{Title: "Local draft", State: "open"}}, + {"T2-another-draft.md", issue.Issue{Title: "Another draft", State: "open"}}, + }) + + tests := []struct { + id string + wantTitle string + wantErr bool + }{ + {"1", "First issue", false}, + {"2", "Second issue", false}, + {"10", "Tenth issue", false}, + {"3", "Closed issue", false}, // finds closed issues + {"T1", "Local draft", false}, + {"T2", "Another draft", false}, + {"t1", "Local draft", false}, // case-insensitive T-prefix + {"t2", "Another draft", false}, + {"99", "", true}, // number not found + {"T99", "", true}, // T-id not found + {"abc", "", true}, // not numeric, not T-prefixed + } + + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + iss, err := findLocalByID(root, tt.id) + if tt.wantErr { + if err == nil { + t.Errorf("findLocalByID(%q): expected error, got nil", tt.id) + } + return + } + if err != nil { + t.Fatalf("findLocalByID(%q): unexpected error: %v", tt.id, err) + } + if iss.Title != tt.wantTitle { + t.Errorf("findLocalByID(%q): Title = %q, want %q", tt.id, iss.Title, tt.wantTitle) + } + }) + } +} diff --git a/cmd/view.go b/cmd/view.go new file mode 100644 index 0000000..5a02a2c --- /dev/null +++ b/cmd/view.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" +) + +var viewCmd = &cobra.Command{ + Use: "view <number>", + Short: "Open an issue in the default editor", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + root, err := issuesRoot() + if err != nil { + return err + } + + iss, err := findLocalByID(root, args[0]) + if err != nil { + return err + } + + editor := os.Getenv("VISUAL") + if editor == "" { + editor = os.Getenv("EDITOR") + } + if editor == "" { + return fmt.Errorf("no editor set: define $VISUAL or $EDITOR") + } + + parts := strings.Fields(editor) + c := exec.Command(parts[0], append(parts[1:], iss.Path)...) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() + }, +} diff --git a/go.mod b/go.mod index cb7e79d..54cf4cc 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,15 @@ module github.com/jamesjohnsdev/issues go 1.26.4 require ( - github.com/fatih/color v1.19.0 // indirect + github.com/fatih/color v1.19.0 + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/sys v0.42.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cbb0168..489d6d6 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,7 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/gh/client.go b/internal/gh/client.go index 635e454..246c676 100644 --- a/internal/gh/client.go +++ b/internal/gh/client.go @@ -170,6 +170,14 @@ func Update(iss *issue.Issue) error { return nil } +// Delete permanently deletes a GitHub issue. +func Delete(number int) error { + if _, err := run("gh", "issue", "delete", fmt.Sprintf("%d", number), "--yes"); err != nil { + return fmt.Errorf("gh issue delete %d: %w", number, err) + } + return nil +} + func Now() *time.Time { t := time.Now().UTC().Truncate(time.Second) return &t diff --git a/internal/issue/fuzz_test.go b/internal/issue/fuzz_test.go new file mode 100644 index 0000000..465b0b1 --- /dev/null +++ b/internal/issue/fuzz_test.go @@ -0,0 +1,111 @@ +package issue + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func FuzzSlug(f *testing.F) { + f.Add("Hello World") + f.Add("") + f.Add("Fix: bug #42!") + f.Add("---hello---") + f.Add("!!! ???") + f.Add(strings.Repeat("a", 100)) + f.Add("enable flag passing to filter status on issue list") + + f.Fuzz(func(t *testing.T, s string) { + result := Slug(s) + + if len(result) > 50 { + t.Errorf("Slug(%q) length %d > 50", s, len(result)) + } + + if strings.HasPrefix(result, "-") { + t.Errorf("Slug(%q) = %q starts with '-'", s, result) + } + if strings.HasSuffix(result, "-") { + t.Errorf("Slug(%q) = %q ends with '-'", s, result) + } + + if strings.Contains(result, "--") { + t.Errorf("Slug(%q) = %q contains consecutive dashes", s, result) + } + + for _, r := range result { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + t.Errorf("Slug(%q) = %q contains invalid char %q", s, result, r) + } + } + }) +} + +func FuzzParse(f *testing.F) { + f.Add("---\ntitle: Hello\nstate: open\n---\n") + f.Add("---\ntitle: Hello\nstate: open\n---\n\nsome body\n") + f.Add("---\nnumber: 42\ntitle: Bug\nstate: closed\n---\n") + f.Add("not frontmatter") + f.Add("") + f.Add("---\n") + f.Add("---\n---\n") + + f.Fuzz(func(t *testing.T, content string) { + path := filepath.Join(t.TempDir(), "issue.md") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + iss, err := Parse(path) + if err != nil { + return // errors for malformed input are expected + } + + if iss == nil { + t.Fatal("Parse returned nil issue without error") + } + if iss.Path != path { + t.Errorf("Path = %q, want %q", iss.Path, path) + } + }) +} + +func FuzzWriteParse(f *testing.F) { + f.Add("Hello", "open", "") + f.Add("Fix the bug", "closed", "Some body text\n") + f.Add("", "open", "") + f.Add("feat: add OAuth2", "open", "## Description\nDetailed body.\n") + f.Add("title with\nnewline", "open", "") + + f.Fuzz(func(t *testing.T, title, state, body string) { + iss := &Issue{Title: title, State: state, Body: body} + path := filepath.Join(t.TempDir(), "issue.md") + + if err := Write(path, iss); err != nil { + t.Fatalf("Write failed: %v", err) + } + + got, err := Parse(path) + if err != nil { + t.Fatalf("Parse failed after successful Write: %v", err) + } + + if got.Title != title { + t.Errorf("Title mismatch: got %q, want %q", got.Title, title) + } + + // Body: Write always appends a trailing newline to non-empty bodies. + // Skip the body check when it contains "\n---" (confuses frontmatter scanner) + // or starts with "\n" (Parse strips leading newlines via TrimLeft). + if !strings.Contains(body, "\n---") && !strings.HasPrefix(body, "\n") { + wantBody := body + if body != "" && !strings.HasSuffix(body, "\n") { + wantBody += "\n" + } + if got.Body != wantBody { + t.Errorf("Body mismatch: got %q, want %q", got.Body, wantBody) + } + } + }) +} diff --git a/internal/issue/issue_test.go b/internal/issue/issue_test.go new file mode 100644 index 0000000..00e262e --- /dev/null +++ b/internal/issue/issue_test.go @@ -0,0 +1,248 @@ +package issue + +import ( + "os" + "path/filepath" + "testing" +) + +// writeTemp writes content to a temp file and returns its path. +func writeTemp(t *testing.T, content string) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "issue-*.md") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(content); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return f.Name() +} + +// checkIssue compares issue fields, excluding Path which is set from the file path. +func checkIssue(t *testing.T, got, want *Issue) { + t.Helper() + if got.Number != want.Number { + t.Errorf("Number: got %d, want %d", got.Number, want.Number) + } + if got.Title != want.Title { + t.Errorf("Title: got %q, want %q", got.Title, want.Title) + } + if got.State != want.State { + t.Errorf("State: got %q, want %q", got.State, want.State) + } + if got.Body != want.Body { + t.Errorf("Body: got %q, want %q", got.Body, want.Body) + } + if got.Milestone != want.Milestone { + t.Errorf("Milestone: got %q, want %q", got.Milestone, want.Milestone) + } + if len(got.Labels) != len(want.Labels) { + t.Errorf("Labels: got %v, want %v", got.Labels, want.Labels) + } else { + for i := range want.Labels { + if got.Labels[i] != want.Labels[i] { + t.Errorf("Labels[%d]: got %q, want %q", i, got.Labels[i], want.Labels[i]) + } + } + } + if len(got.Assignees) != len(want.Assignees) { + t.Errorf("Assignees: got %v, want %v", got.Assignees, want.Assignees) + } else { + for i := range want.Assignees { + if got.Assignees[i] != want.Assignees[i] { + t.Errorf("Assignees[%d]: got %q, want %q", i, got.Assignees[i], want.Assignees[i]) + } + } + } +} + +func TestParse(t *testing.T) { + tests := []struct { + name string + content string + want Issue + wantErr bool + }{ + { + name: "minimal valid issue", + content: "---\ntitle: Hello\nstate: open\n---\n", + want: Issue{Title: "Hello", State: "open"}, + }, + { + name: "with body", + content: "---\ntitle: Hello\nstate: open\n---\n\nsome body text\n", + want: Issue{Title: "Hello", State: "open", Body: "some body text\n"}, + }, + { + name: "with github number", + content: "---\nnumber: 42\ntitle: My issue\nstate: open\n---\n", + want: Issue{Number: 42, Title: "My issue", State: "open"}, + }, + { + name: "closed state", + content: "---\ntitle: Done\nstate: closed\n---\n", + want: Issue{Title: "Done", State: "closed"}, + }, + { + name: "with labels", + content: "---\ntitle: Bug\nstate: open\nlabels:\n - bug\n - urgent\n---\n", + want: Issue{Title: "Bug", State: "open", Labels: []string{"bug", "urgent"}}, + }, + { + name: "with assignees", + content: "---\ntitle: Task\nstate: open\nassignees:\n - alice\n - bob\n---\n", + want: Issue{Title: "Task", State: "open", Assignees: []string{"alice", "bob"}}, + }, + { + name: "with milestone", + content: "---\ntitle: Feature\nstate: open\nmilestone: v1.0\n---\n", + want: Issue{Title: "Feature", State: "open", Milestone: "v1.0"}, + }, + { + name: "multiline body", + content: "---\ntitle: Big issue\nstate: open\n---\n\nLine one.\n\nLine two.\n", + want: Issue{Title: "Big issue", State: "open", Body: "Line one.\n\nLine two.\n"}, + }, + { + name: "missing frontmatter delimiter", + content: "no frontmatter here", + wantErr: true, + }, + { + name: "unclosed frontmatter", + content: "---\ntitle: Hello\n", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeTemp(t, tt.content) + got, err := Parse(path) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Path != path { + t.Errorf("Path: got %q, want %q", got.Path, path) + } + checkIssue(t, got, &tt.want) + }) + } + + t.Run("non-existent file returns error", func(t *testing.T) { + _, err := Parse("/no/such/file.md") + if err == nil { + t.Error("expected error for non-existent file, got nil") + } + }) +} + +func TestWriteParse(t *testing.T) { + tests := []struct { + name string + iss Issue + }{ + { + name: "basic open issue", + iss: Issue{Title: "Hello", State: "open"}, + }, + { + name: "issue with body", + iss: Issue{Title: "With body", State: "open", Body: "some body text\n"}, + }, + { + name: "github issue with number", + iss: Issue{Number: 42, Title: "Numbered", State: "open"}, + }, + { + name: "closed issue", + iss: Issue{Title: "Done", State: "closed"}, + }, + { + name: "issue with labels", + iss: Issue{Title: "Labelled", State: "open", Labels: []string{"bug", "urgent"}}, + }, + { + name: "issue with assignees", + iss: Issue{Title: "Assigned", State: "open", Assignees: []string{"alice", "bob"}}, + }, + { + name: "issue with milestone", + iss: Issue{Title: "Milestoned", State: "open", Milestone: "v1.0"}, + }, + { + name: "all fields", + iss: Issue{ + Number: 7, + Title: "Full issue", + State: "open", + Labels: []string{"bug"}, + Assignees: []string{"alice"}, + Milestone: "v2.0", + Body: "body here\n", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "issue.md") + if err := Write(path, &tt.iss); err != nil { + t.Fatalf("Write: %v", err) + } + got, err := Parse(path) + if err != nil { + t.Fatalf("Parse after Write: %v", err) + } + checkIssue(t, got, &tt.iss) + }) + } +} + +func TestFilename(t *testing.T) { + tests := []struct { + name string + iss Issue + want string + }{ + { + name: "basic numbered issue", + iss: Issue{Number: 1, Title: "Hello World"}, + want: "1-hello-world.md", + }, + { + name: "special chars in title", + iss: Issue{Number: 42, Title: "Fix: bug #42!"}, + want: "42-fix-bug-42.md", + }, + { + name: "double digit number", + iss: Issue{Number: 10, Title: "Ten"}, + want: "10-ten.md", + }, + { + name: "long title is slugged and truncated", + iss: Issue{Number: 3, Title: "this is a very long title that goes way beyond fifty characters"}, + want: "3-this-is-a-very-long-title-that-goes-way-beyond.md", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Filename(&tt.iss) + if got != tt.want { + t.Errorf("Filename() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/issue/slug_test.go b/internal/issue/slug_test.go new file mode 100644 index 0000000..d0f5dfb --- /dev/null +++ b/internal/issue/slug_test.go @@ -0,0 +1,75 @@ +package issue + +import "testing" + +func TestSlug(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "basic words", + input: "Hello World", + want: "hello-world", + }, + { + name: "already lowercase", + input: "hello world", + want: "hello-world", + }, + { + name: "special characters stripped", + input: "Fix: bug #42!", + want: "fix-bug-42", + }, + { + name: "consecutive special chars collapsed to single dash", + input: "hello world", + want: "hello-world", + }, + { + name: "leading and trailing special chars trimmed", + input: "---hello world---", + want: "hello-world", + }, + { + name: "numbers preserved", + input: "issue 123 fix", + want: "issue-123-fix", + }, + { + name: "exactly 50 chars not truncated", + input: "enable flag passing to filter status on issue list", + want: "enable-flag-passing-to-filter-status-on-issue-list", + }, + { + name: "over 50 chars truncated at word boundary", + input: "this is a very long title that goes way beyond fifty characters", + want: "this-is-a-very-long-title-that-goes-way-beyond", + }, + { + name: "empty string", + input: "", + want: "", + }, + { + name: "only special chars", + input: "!!! ???", + want: "", + }, + { + name: "mixed alphanumeric and special", + input: "feat(auth): add OAuth2 support", + want: "feat-auth-add-oauth2-support", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Slug(tt.input) + if got != tt.want { + t.Errorf("Slug(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +}