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 ` - opens an issue by number in the default editor.
+- `issues close ` - 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 ",
+ 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 ",
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 ",
+ 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 ",
+ 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)
+ }
+ })
+ }
+}