From d2b7d37ffda07f67c8b734e8efa6b442e12b89ae Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:39:34 +1000 Subject: [PATCH 01/23] ci: added GHA workflows - general CI checks for code quality and correct builds - standard test runs on PRs - fuzz testing --- .github/workflows/ci.yml | 76 +++++++++++++++++++++++++++ .github/workflows/fuzz.yml | 79 +++++++++++++++++++++++++++++ .github/workflows/semgrep.yml | 23 +++++++++ .github/workflows/workflow-lint.yml | 44 ++++++++++++++++ 4 files changed, 222 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/fuzz.yml create mode 100644 .github/workflows/semgrep.yml create mode 100644 .github/workflows/workflow-lint.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d22bf32 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +permissions: + contents: read + +on: + pull_request: + branches: [main] + paths: + - "**/*.go" + - "transpiler/go.mod" + - "transpiler/go.sum" + - "lsp/go.mod" + - "lsp/go.sum" + - ".golangci*" + workflow_call: + inputs: + skipTests: + description: "Skip tests, useful when there is a dedicated CI job for tests" + default: false + required: false + type: boolean + +jobs: + build: + name: Build (${{ matrix.module }}) + runs-on: ubuntu-latest + timeout-minutes: 5 + + strategy: + matrix: + module: [transpiler, lsp] + + defaults: + run: + working-directory: ${{ matrix.module }} + + 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: ${{ matrix.module }}/go.mod + cache-dependency-path: ${{ matrix.module }}/go.sum + + - name: golangci-lint + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9 + with: + working-directory: ${{ matrix.module }} + + - 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: Compile Tests + if: ${{ inputs.skipTests }} + run: go test -exec /bin/true ./... + + - name: Test + if: ${{ !inputs.skipTests }} + run: go test -v -count=1 -race -shuffle=on -coverprofile=coverage.txt ./... + + - name: Coverage summary + if: ${{ !inputs.skipTests }} + 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 From 153e1502b887a84aad475fb512fa2fd1fadffcbc Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:40:48 +1000 Subject: [PATCH 02/23] ci: dependabot --- .github/dependabot.yml | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e09bdd3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,46 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/transpiler" + 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: "gomod" + directory: "/lsp" + 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 From 2ae962dc4d917cf4bc9457c761cc73822f8fc7bf Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:42:01 +1000 Subject: [PATCH 03/23] chore: add codeowners --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..61ba9a5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @jamesjohnsdev From 88f81da3130db16c72eaa8c41155641cba2a21e3 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:04:31 +1000 Subject: [PATCH 04/23] feat: added command enables users to open isses with default editor synatax --- README.md | 1 + cmd/root.go | 1 + cmd/view.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 cmd/view.go diff --git a/README.md b/README.md index 27bc337..13ebbda 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ also for AI augmented workflows, where agents can have a source for development. - `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 view ` - opens an issue by number in the default editor. - `issue sync` - syncs all issues in the current directory using the GitHub CLI. - `issue help`` - shows help for the `issue` command. diff --git a/cmd/root.go b/cmd/root.go index c4df505..a0318bd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -61,4 +61,5 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e rootCmd.AddCommand(syncCmd) rootCmd.AddCommand(pushCmd) rootCmd.AddCommand(pullCmd) + rootCmd.AddCommand(viewCmd) } diff --git a/cmd/view.go b/cmd/view.go new file mode 100644 index 0000000..ec5b27d --- /dev/null +++ b/cmd/view.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + + "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 { + var number int + if _, err := fmt.Sscanf(args[0], "%d", &number); err != nil { + return fmt.Errorf("invalid issue number: %s", args[0]) + } + + root, err := issuesRoot() + if err != nil { + return err + } + + iss, err := findLocalByNumber(root, number) + 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") + } + + c := exec.Command(editor, iss.Path) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() + }, +} From f6233a908a3c03eae25e8a26ebe3e5af130182db Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:10 +1000 Subject: [PATCH 05/23] doc: updated readme binary name didn't match actually command --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 13ebbda..e68ff96 100644 --- a/README.md +++ b/README.md @@ -11,24 +11,24 @@ 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 view ` - opens an issue by number in the default editor. -- `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 view ` - opens an issue by number in the default editor. +- `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. From 84ce84dab76e7d6e41cd565d6efcbddb72201f30 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:35 +1000 Subject: [PATCH 06/23] feat: added `complete` command --- cmd/complete.go | 54 +++++++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + 2 files changed, 55 insertions(+) create mode 100644 cmd/complete.go diff --git a/cmd/complete.go b/cmd/complete.go new file mode 100644 index 0000000..efc9cae --- /dev/null +++ b/cmd/complete.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/fatih/color" + "github.com/jamesjohnsdev/issues/internal/issue" + "github.com/spf13/cobra" +) + +var completeCmd = &cobra.Command{ + Use: "complete ", + Short: "Mark an issue as closed", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var number int + if _, err := fmt.Sscanf(args[0], "%d", &number); err != nil { + return fmt.Errorf("invalid issue number: %s", args[0]) + } + + root, err := issuesRoot() + if err != nil { + return err + } + + iss, err := findLocalByNumber(root, number) + if err != nil { + return err + } + + if iss.State == "closed" { + fmt.Printf("Issue #%d is already closed.\n", number) + 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 #%d: %s\n", color.GreenString("Completed"), number, iss.Title) + return nil + }, +} diff --git a/cmd/root.go b/cmd/root.go index a0318bd..8b73836 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -62,4 +62,5 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e rootCmd.AddCommand(pushCmd) rootCmd.AddCommand(pullCmd) rootCmd.AddCommand(viewCmd) + rootCmd.AddCommand(completeCmd) } From bda37188dc33d566613aa6155088beec190208f2 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:23:16 +1000 Subject: [PATCH 07/23] feat: enabled creation direct passthrough to editor --- README.md | 3 ++- cmd/create.go | 37 +++++++++++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e68ff96..0795816 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ also for AI augmented workflows, where agents can have a source for development. - `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 ` - 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 sync` - syncs all issues in the current directory using the GitHub CLI. - `issues help` - shows help for the `issues` command. diff --git a/cmd/create.go b/cmd/create.go index 1a3dd03..0b440a2 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -11,17 +11,23 @@ import ( "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 nil + } + 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 @@ -37,9 +43,16 @@ var createCmd = &cobra.Command{ } } - 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,6 +63,9 @@ var createCmd = &cobra.Command{ if editor == "" { editor = os.Getenv("EDITOR") } + if createEditorFlag && editor == "" { + return fmt.Errorf("no editor set: define $VISUAL or $EDITOR") + } if editor != "" { c := exec.Command(editor, path) c.Stdin = os.Stdin @@ -58,7 +74,20 @@ var createCmd = &cobra.Command{ _ = 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") +} From bd486a13c64b97027821a07baa402fd3c0756f1e Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:29:03 +1000 Subject: [PATCH 08/23] refactor: rename complete => close to match GH terminology doc: added `close` command to readme --- README.md | 1 + cmd/{complete.go => close.go} | 6 +++--- cmd/root.go | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) rename cmd/{complete.go => close.go} (87%) diff --git a/README.md b/README.md index 0795816..d955fbc 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ also for AI augmented workflows, where agents can have a source for development. - `issues create <title>` - 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. diff --git a/cmd/complete.go b/cmd/close.go similarity index 87% rename from cmd/complete.go rename to cmd/close.go index efc9cae..792c26b 100644 --- a/cmd/complete.go +++ b/cmd/close.go @@ -10,8 +10,8 @@ import ( "github.com/spf13/cobra" ) -var completeCmd = &cobra.Command{ - Use: "complete <number>", +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 { @@ -48,7 +48,7 @@ var completeCmd = &cobra.Command{ return err } - fmt.Printf("%s #%d: %s\n", color.GreenString("Completed"), number, iss.Title) + fmt.Printf("%s #%d: %s\n", color.GreenString("Closed"), number, iss.Title) return nil }, } diff --git a/cmd/root.go b/cmd/root.go index 8b73836..ac96782 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -62,5 +62,5 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e rootCmd.AddCommand(pushCmd) rootCmd.AddCommand(pullCmd) rootCmd.AddCommand(viewCmd) - rootCmd.AddCommand(completeCmd) + rootCmd.AddCommand(closeCmd) } From a7c1203705aee768f0a34b781b230d76a6729366 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:39:45 +1000 Subject: [PATCH 09/23] feat: improved display for issue list --- cmd/list.go | 67 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/cmd/list.go b/cmd/list.go index 133df5d..fedde96 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,71 @@ 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 + fmt.Sscanf(idFromPath(filtered[i].Path), "T%d", &ti) + fmt.Sscanf(idFromPath(filtered[j].Path), "T%d", &tj) + 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 }, From 722a30b6658da7a2ae554611bb59f8f3eaebc817 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:44:09 +1000 Subject: [PATCH 10/23] fix: correct parsing of local issue numbers --- cmd/close.go | 11 +++-------- cmd/push.go | 6 +----- cmd/util.go | 30 ++++++++++++++++++++++++++++++ cmd/view.go | 7 +------ 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/cmd/close.go b/cmd/close.go index 792c26b..f08491b 100644 --- a/cmd/close.go +++ b/cmd/close.go @@ -15,23 +15,18 @@ var closeCmd = &cobra.Command{ Short: "Mark an issue as closed", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - var number int - if _, err := fmt.Sscanf(args[0], "%d", &number); err != nil { - return fmt.Errorf("invalid issue number: %s", args[0]) - } - root, err := issuesRoot() if err != nil { return err } - iss, err := findLocalByNumber(root, number) + iss, err := findLocalByID(root, args[0]) if err != nil { return err } if iss.State == "closed" { - fmt.Printf("Issue #%d is already closed.\n", number) + fmt.Printf("Issue %s is already closed.\n", args[0]) return nil } @@ -48,7 +43,7 @@ var closeCmd = &cobra.Command{ return err } - fmt.Printf("%s #%d: %s\n", color.GreenString("Closed"), number, iss.Title) + fmt.Printf("%s %s: %s\n", color.GreenString("Closed"), args[0], iss.Title) return nil }, } diff --git a/cmd/push.go b/cmd/push.go index 5416b72..0ac52b4 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 } diff --git a/cmd/util.go b/cmd/util.go index 51bd3d3..e18a61f 100644 --- a/cmd/util.go +++ b/cmd/util.go @@ -72,6 +72,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 + var number int + if _, err := fmt.Sscanf(id, "%d", &number); 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/view.go b/cmd/view.go index ec5b27d..d618cb8 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -13,17 +13,12 @@ var viewCmd = &cobra.Command{ Short: "Open an issue in the default editor", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - var number int - if _, err := fmt.Sscanf(args[0], "%d", &number); err != nil { - return fmt.Errorf("invalid issue number: %s", args[0]) - } - root, err := issuesRoot() if err != nil { return err } - iss, err := findLocalByNumber(root, number) + iss, err := findLocalByID(root, args[0]) if err != nil { return err } From 1b9e74c3c34ebf92a2202280d3551afd66f71abd Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:01:01 +1000 Subject: [PATCH 11/23] test: test suites added --- cmd/close_test.go | 114 ++++++++++++++ cmd/create_test.go | 90 +++++++++++ cmd/list_test.go | 147 ++++++++++++++++++ cmd/util_test.go | 293 +++++++++++++++++++++++++++++++++++ internal/issue/issue_test.go | 246 +++++++++++++++++++++++++++++ internal/issue/slug_test.go | 75 +++++++++ 6 files changed, 965 insertions(+) create mode 100644 cmd/close_test.go create mode 100644 cmd/create_test.go create mode 100644 cmd/list_test.go create mode 100644 cmd/util_test.go create mode 100644 internal/issue/issue_test.go create mode 100644 internal/issue/slug_test.go 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_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/list_test.go b/cmd/list_test.go new file mode 100644 index 0000000..498f8bf --- /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/util_test.go b/cmd/util_test.go new file mode 100644 index 0000000..7486d64 --- /dev/null +++ b/cmd/util_test.go @@ -0,0 +1,293 @@ +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() + w.Close() + 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/internal/issue/issue_test.go b/internal/issue/issue_test.go new file mode 100644 index 0000000..c4dc504 --- /dev/null +++ b/internal/issue/issue_test.go @@ -0,0 +1,246 @@ +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) + } + f.Close() + 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) + } + }) + } +} From 2530941aaebc4e58e6d00e28c389020f59dd33b9 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 00:03:31 +1000 Subject: [PATCH 12/23] test: added fuzz testing --- cmd/fuzz_test.go | 70 +++++++++++++++++++++++ internal/issue/fuzz_test.go | 111 ++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 cmd/fuzz_test.go create mode 100644 internal/issue/fuzz_test.go diff --git a/cmd/fuzz_test.go b/cmd/fuzz_test.go new file mode 100644 index 0000000..65a543e --- /dev/null +++ b/cmd/fuzz_test.go @@ -0,0 +1,70 @@ +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() { os.RemoveAll(root) }) + + 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/internal/issue/fuzz_test.go b/internal/issue/fuzz_test.go new file mode 100644 index 0000000..44f2301 --- /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---" — that sequence would + // confuse the frontmatter end-marker scan and is a known parser limitation. + if !strings.Contains(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) + } + } + }) +} From 7d1b73b5fa7949c91c8ee51c5d67a8619eca6b4c Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:25:37 +1000 Subject: [PATCH 13/23] chore: cleanup old references to `issue` command rather than `issues` --- cmd/sync.go | 2 +- cmd/util.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 e18a61f..d60e9c9 100644 --- a/cmd/util.go +++ b/cmd/util.go @@ -19,7 +19,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 } From 7d9de9b88b0827faa4fad3543dcc3df9d8326142 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:15:00 +1000 Subject: [PATCH 14/23] feat: delete command deletes local issue after checking sync status and providing optional delete of remote copy --- cmd/delete.go | 57 +++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + internal/gh/client.go | 8 ++++++ 3 files changed, 66 insertions(+) create mode 100644 cmd/delete.go 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/root.go b/cmd/root.go index ac96782..dc324fd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -63,4 +63,5 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e rootCmd.AddCommand(pullCmd) rootCmd.AddCommand(viewCmd) rootCmd.AddCommand(closeCmd) + rootCmd.AddCommand(deleteCmd) } 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 From 440fd43bfcc5e681caf099316ce4a324a4afdc4e Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:18:17 +1000 Subject: [PATCH 15/23] ci: fix main CI workflow it was incorrectly referencing two separate builds rather than the singular build required. This is a remnent of reusing this from another project. --- .github/workflows/ci.yml | 36 +++--------------------------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d22bf32..7ab9e77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,35 +6,13 @@ permissions: on: pull_request: branches: [main] - paths: - - "**/*.go" - - "transpiler/go.mod" - - "transpiler/go.sum" - - "lsp/go.mod" - - "lsp/go.sum" - - ".golangci*" - workflow_call: - inputs: - skipTests: - description: "Skip tests, useful when there is a dedicated CI job for tests" - default: false - required: false - type: boolean jobs: build: - name: Build (${{ matrix.module }}) + name: Build runs-on: ubuntu-latest timeout-minutes: 5 - strategy: - matrix: - module: [transpiler, lsp] - - defaults: - run: - working-directory: ${{ matrix.module }} - steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -43,13 +21,11 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: - go-version-file: ${{ matrix.module }}/go.mod - cache-dependency-path: ${{ matrix.module }}/go.sum + go-version-file: go.mod + cache-dependency-path: go.sum - name: golangci-lint uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9 - with: - working-directory: ${{ matrix.module }} - name: govulncheck run: go run golang.org/x/vuln/cmd/govulncheck@v1.3.0 ./... @@ -63,14 +39,8 @@ jobs: - name: Build run: go build -o /dev/null ./... - - name: Compile Tests - if: ${{ inputs.skipTests }} - run: go test -exec /bin/true ./... - - name: Test - if: ${{ !inputs.skipTests }} run: go test -v -count=1 -race -shuffle=on -coverprofile=coverage.txt ./... - name: Coverage summary - if: ${{ !inputs.skipTests }} run: go tool cover -func=coverage.txt >> "$GITHUB_STEP_SUMMARY" From abd6765c9852cedc17e5b1c052206d68709742eb Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:46:38 +1000 Subject: [PATCH 16/23] chore: formating and unchecked errs --- cmd/create.go | 4 +++- cmd/fuzz_test.go | 6 +++++- cmd/list.go | 8 ++++++-- cmd/list_test.go | 2 +- cmd/pull.go | 4 +++- cmd/push.go | 4 +++- cmd/util_test.go | 14 ++++++++------ internal/issue/fuzz_test.go | 2 +- internal/issue/issue_test.go | 4 +++- 9 files changed, 33 insertions(+), 15 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index 0b440a2..ac98a26 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -36,7 +36,9 @@ 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 } diff --git a/cmd/fuzz_test.go b/cmd/fuzz_test.go index 65a543e..293d238 100644 --- a/cmd/fuzz_test.go +++ b/cmd/fuzz_test.go @@ -25,7 +25,11 @@ func FuzzFindLocalByID(f *testing.F) { if err != nil { f.Fatal(err) } - f.Cleanup(func() { os.RemoveAll(root) }) + f.Cleanup(func() { + if err := os.RemoveAll(root); err != nil { + f.Fatal(err) + } + }) for _, dir := range []string{ filepath.Join(root, "open"), diff --git a/cmd/list.go b/cmd/list.go index fedde96..f605f88 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -41,8 +41,12 @@ var listCmd = &cobra.Command{ } // Both local: compare T-numbers var ti, tj int - fmt.Sscanf(idFromPath(filtered[i].Path), "T%d", &ti) - fmt.Sscanf(idFromPath(filtered[j].Path), "T%d", &tj) + 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 }) diff --git a/cmd/list_test.go b/cmd/list_test.go index 498f8bf..4006a94 100644 --- a/cmd/list_test.go +++ b/cmd/list_test.go @@ -114,7 +114,7 @@ func TestList(t *testing.T) { if posFirst < 0 || posSecond < 0 || posTenth < 0 { t.Fatalf("not all issues in output: %q", out) } - if !(posFirst < posSecond && posSecond < posTenth) { + if posFirst >= posSecond || posSecond >= posTenth { t.Errorf("wrong order: First@%d Second@%d Tenth@%d — want First < Second < Tenth", posFirst, posSecond, posTenth) } 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 0ac52b4..e0fc42c 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -56,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/util_test.go b/cmd/util_test.go index 7486d64..6648edb 100644 --- a/cmd/util_test.go +++ b/cmd/util_test.go @@ -93,7 +93,9 @@ func captureStdout(t *testing.T, fn func()) string { old := os.Stdout os.Stdout = w fn() - w.Close() + if err := w.Close(); err != nil { + t.Fatal(err) + } os.Stdout = old var buf bytes.Buffer if _, err := buf.ReadFrom(r); err != nil { @@ -263,14 +265,14 @@ func TestFindLocalByID(t *testing.T) { {"1", "First issue", false}, {"2", "Second issue", false}, {"10", "Tenth issue", false}, - {"3", "Closed issue", false}, // finds closed issues + {"3", "Closed issue", false}, // finds closed issues {"T1", "Local draft", false}, {"T2", "Another draft", false}, - {"t1", "Local draft", false}, // case-insensitive T-prefix + {"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 + {"99", "", true}, // number not found + {"T99", "", true}, // T-id not found + {"abc", "", true}, // not numeric, not T-prefixed } for _, tt := range tests { diff --git a/internal/issue/fuzz_test.go b/internal/issue/fuzz_test.go index 44f2301..3346b4f 100644 --- a/internal/issue/fuzz_test.go +++ b/internal/issue/fuzz_test.go @@ -35,7 +35,7 @@ func FuzzSlug(f *testing.F) { } for _, r := range result { - if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-') { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { t.Errorf("Slug(%q) = %q contains invalid char %q", s, result, r) } } diff --git a/internal/issue/issue_test.go b/internal/issue/issue_test.go index c4dc504..00e262e 100644 --- a/internal/issue/issue_test.go +++ b/internal/issue/issue_test.go @@ -16,7 +16,9 @@ func writeTemp(t *testing.T, content string) string { if _, err := f.WriteString(content); err != nil { t.Fatal(err) } - f.Close() + if err := f.Close(); err != nil { + t.Fatal(err) + } return f.Name() } From c2e3b8a3d5c33b6a392b24a50a4c0cb0099aace8 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:56:22 +1000 Subject: [PATCH 17/23] fix(root): rename root command name to "issues" Binary is named "issues" via go install; Use field was "issue", causing help text to show the wrong command name. --- cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index dc324fd..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", } From 5328d2ff5e794afed42eb1bf40bff1a9621f446b Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:56:29 +1000 Subject: [PATCH 18/23] ci: point dependabot at root go module /transpiler and /lsp directories do not exist; dependabot was silently skipping go dependency updates. Remove the dead block and point the single gomod entry at /. --- .github/dependabot.yml | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e09bdd3..28591cc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,22 +1,7 @@ version: 2 updates: - package-ecosystem: "gomod" - directory: "/transpiler" - 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: "gomod" - directory: "/lsp" + directory: "/" schedule: interval: "weekly" day: "saturday" From 257228cc0ae6d19a13bdec6332d91c74b5b69686 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:56:39 +1000 Subject: [PATCH 19/23] fix(util): reject partial numeric ids such as "123abc" fmt.Sscanf does not require consuming the full input, so "123abc" silently parsed as 123. strconv.Atoi fails on any non-numeric input. --- cmd/util.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/util.go b/cmd/util.go index d60e9c9..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" @@ -90,8 +91,8 @@ func findLocalByID(root, id string) (*issue.Issue, error) { } // GitHub issue number - var number int - if _, err := fmt.Sscanf(id, "%d", &number); err != nil { + number, err := strconv.Atoi(id) + if err != nil { return nil, fmt.Errorf("invalid issue id: %s", id) } for _, iss := range issues { From 5869e485e7143a25affc85b1db5dd30bc274f6be Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:56:46 +1000 Subject: [PATCH 20/23] fix(create): three editor mode correctness bugs - Reject positional args when --editor is set instead of silently ignoring them - Remove the created file before returning the no-editor error to avoid leaving an empty orphan file behind - Split editor env var on whitespace so flags like "--wait" are passed correctly to exec.Command --- cmd/create.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index ac98a26..3de9d40 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "github.com/fatih/color" "github.com/jamesjohnsdev/issues/internal/issue" @@ -18,7 +19,7 @@ var createCmd = &cobra.Command{ Short: "Create a new local issue", Args: func(cmd *cobra.Command, args []string) error { if createEditorFlag { - return nil + return cobra.NoArgs(cmd, args) } return cobra.ExactArgs(1)(cmd, args) }, @@ -66,10 +67,12 @@ var createCmd = &cobra.Command{ 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 From 5f90b973a98b83ca95ebf5c15984b2eaa77cc274 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:56:59 +1000 Subject: [PATCH 21/23] fix(view): split editor string to support flags VISUAL="code --wait" was passed as a single argument to exec.Command, causing a no-such-file error. Split on whitespace before constructing the command. --- cmd/view.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/view.go b/cmd/view.go index d618cb8..5a02a2c 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "strings" "github.com/spf13/cobra" ) @@ -31,7 +32,8 @@ var viewCmd = &cobra.Command{ return fmt.Errorf("no editor set: define $VISUAL or $EDITOR") } - c := exec.Command(editor, iss.Path) + 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 From 32b811ec9fbe3805d976ff6a097180519b196973 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:57:06 +1000 Subject: [PATCH 22/23] test(fuzz): skip round-trip body check for leading-newline inputs Parse calls strings.TrimLeft on the body, stripping leading newlines. Bodies that start with "\n" will never round-trip, so exclude them from the check the same way "\n---" inputs are already excluded. --- internal/issue/fuzz_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/issue/fuzz_test.go b/internal/issue/fuzz_test.go index 3346b4f..465b0b1 100644 --- a/internal/issue/fuzz_test.go +++ b/internal/issue/fuzz_test.go @@ -96,9 +96,9 @@ func FuzzWriteParse(f *testing.F) { } // Body: Write always appends a trailing newline to non-empty bodies. - // Skip the body check when it contains "\n---" — that sequence would - // confuse the frontmatter end-marker scan and is a known parser limitation. - if !strings.Contains(body, "\n---") { + // 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" From 5f3127ea489eb38a9ee7cdb976463d625cd38751 Mon Sep 17 00:00:00 2001 From: James Johns <192176108+jamesjohnsdev@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:03:16 +1000 Subject: [PATCH 23/23] chore: mod tidy --- go.mod | 9 ++++++--- go.sum | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) 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=