From ee15fdf867a3738f34668249788b743aad5771b2 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Sun, 22 Feb 2026 13:53:10 +0100 Subject: [PATCH 01/13] add goReleaser --- .gitignore | 3 +++ .goreleaser.yaml | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 .goreleaser.yaml diff --git a/.gitignore b/.gitignore index 99e666a..6474f31 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ bin node_modules/ package-lock.json package.json + +# Added by goreleaser init: +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..db691e2 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,47 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com + +# The lines below are called `modelines`. See `:help modeline` +# Feel free to remove those if you don't want/need to use them. +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 2 + +before: + hooks: + # You may remove this if you don't use go modules. + - go mod tidy + # you may remove this if you don't need go generate + - go generate ./... + +builds: + - main: ./cmd/survey + env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + +archives: + - formats: [tar.gz] + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + formats: [zip] + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" From b2b8c5edcd1c9ec8871ed34839daa6182891a625 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Sun, 22 Feb 2026 14:18:37 +0100 Subject: [PATCH 02/13] add dockerignore --- .dockerignore | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..720f857 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.pm +.git +bin + +.idea +.vscode + +node_modules/ +package-lock.json +package.json + +.gitignore +.goreleaser.yaml + +*.db +*.ext + +Makefile + +# Added by goreleaser init: +dist/ From a0535085a0ea4f70450ea023b24dd4fc18b84790 Mon Sep 17 00:00:00 2001 From: Robin Olsen <129996395+Telikz@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:22:08 +0100 Subject: [PATCH 03/13] Add GitHub Actions workflow for Go project --- .github/workflows/go.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/go.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..0b443f3 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,28 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.20' + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... From 8a2c6582e4921709cad6f97c0657bab0f80ea44e Mon Sep 17 00:00:00 2001 From: Robin Olsen <129996395+Telikz@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:23:09 +0100 Subject: [PATCH 04/13] Update Go version to 1.25.6 in workflow --- .github/workflows/go.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 0b443f3..74d111b 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.20' + go-version: '1.25.6' - name: Build run: go build -v ./... From 8dcf97c6c578036f42eec265513708ed509ffaa4 Mon Sep 17 00:00:00 2001 From: Robin Olsen <129996395+Telikz@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:26:16 +0100 Subject: [PATCH 05/13] Add Apache License 2.0 to the project --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From 133996c73451cae94b8e0ce50389a7dbdd4847bf Mon Sep 17 00:00:00 2001 From: Robin Olsen <129996395+Telikz@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:28:39 +0100 Subject: [PATCH 06/13] Update copyright year and owner in LICENSE file --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 261eeb9..8e13446 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 LazyBachelor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From c1b34a145d038a59a2a4b92372966fef8fc17edb Mon Sep 17 00:00:00 2001 From: Robin Olsen <129996395+Telikz@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:31:17 +0100 Subject: [PATCH 07/13] Add Docker publish workflow to GitHub Actions --- .github/workflows/docker-publish.yml | 98 ++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..c63a19f --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,98 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + schedule: + - cron: '45 15 * * *' + push: + branches: [ "main" ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "main" ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Install the cosign tool except on PR + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 + with: + cosign-release: 'v2.2.4' + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # Sign the resulting Docker image digest except on PRs. + # This will only write to the public Rekor transparency log when the Docker + # repository is public to avoid leaking data. If you would like to publish + # transparency data even for private images, pass --force to cosign below. + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} From 4f30744314b4439096c90eb1e5b347e13d3bc04e Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Sun, 22 Feb 2026 14:39:42 +0100 Subject: [PATCH 08/13] add goreleaser action --- .github/workflows/goreleaser.yml | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/goreleaser.yml diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml new file mode 100644 index 0000000..b8192bc --- /dev/null +++ b/.github/workflows/goreleaser.yml @@ -0,0 +1,35 @@ +name: goreleaser + +on: + push: + tags: + - '*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - + name: Set up Go + uses: actions/setup-go@v6 + - + name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + # either 'goreleaser' (default) or 'goreleaser-pro' + distribution: goreleaser + # 'latest', 'nightly', or a semver + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution + # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} \ No newline at end of file From dbf619a0548446ed22d6f53f5072182cc20dd67f Mon Sep 17 00:00:00 2001 From: Robin Olsen <129996395+Telikz@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:49:15 +0100 Subject: [PATCH 09/13] Update Go version to 1.25.6 in workflow --- .github/workflows/goreleaser.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index b8192bc..b05894e 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -20,6 +20,8 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 + with: + go-version: '1.25.6' - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 @@ -32,4 +34,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Your GoReleaser Pro key, if you are using the 'goreleaser-pro' distribution - # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} \ No newline at end of file + # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} From 118e67dbf4c03181f90a2ae4ce1c9f4366838684 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Sun, 22 Feb 2026 16:16:09 +0100 Subject: [PATCH 10/13] refactor services to use Interface and change name to app --- cmd/pm/main.go | 12 ++----- cmd/survey/init.go | 14 ++++---- cmd/survey/tasks/base.go | 13 +++---- cmd/survey/tasks/codingTask.go | 11 +++--- cmd/survey/tasks/createIssue.go | 14 ++++---- cmd/tui/main.go | 13 ++----- cmd/web/main.go | 16 ++------- internal/models/issue.go | 5 +-- internal/service/config.go | 8 +++++ internal/service/interfaces.go | 28 +++++++++++++++ internal/service/service.go | 23 +++++------- pkg/cli/cli.go | 16 ++++----- pkg/cli/commands/close.go | 6 ++-- pkg/cli/commands/completion.go | 5 +-- pkg/cli/commands/create.go | 3 +- pkg/cli/commands/delete.go | 11 +++--- pkg/cli/commands/ls.go | 3 +- pkg/cli/commands/read.go | 3 +- pkg/cli/commands/root.go | 32 +++++++++++++---- pkg/cli/commands/update.go | 8 +++-- pkg/cli/repl/repl.go | 8 ++--- pkg/task/register.go | 8 ++--- pkg/task/runner.go | 13 ++++--- pkg/task/types.go | 6 ++-- pkg/tui/tui.go | 8 ++--- pkg/tui/views/dashboard/issue_list.go | 12 +++---- pkg/tui/views/dashboard/model.go | 52 +++++++++++++-------------- pkg/tui/views/dashboard/operations.go | 44 +++++++++++------------ pkg/tui/views/views.go | 4 +-- pkg/web/handler/comments.go | 4 +-- pkg/web/handler/context.go | 12 +++---- pkg/web/handler/index.go | 4 +-- pkg/web/handler/issues.go | 24 ++++++------- pkg/web/server/routes.go | 2 +- pkg/web/server/server.go | 6 ++-- pkg/web/web.go | 15 ++++---- 36 files changed, 247 insertions(+), 219 deletions(-) create mode 100644 internal/service/interfaces.go diff --git a/cmd/pm/main.go b/cmd/pm/main.go index d067ef4..3ac39fd 100644 --- a/cmd/pm/main.go +++ b/cmd/pm/main.go @@ -3,20 +3,12 @@ package main import ( "context" + "github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/pkg/cli" ) func main() { - config := cli.CLIConfig{ - RootCmd: "pm", - IssuePrefix: "pm", - BeadsDBPath: "./.pm/db.db", - StatisticsStoragePath: "./.pm/stats.json", - } - - cli := cli.NewCli() - - if err := cli.Run(context.Background(), config); err != nil { + if err := cli.NewCli().Run(context.Background(), service.BaseConfig); err != nil { return } } diff --git a/cmd/survey/init.go b/cmd/survey/init.go index 6016132..6587a51 100644 --- a/cmd/survey/init.go +++ b/cmd/survey/init.go @@ -14,19 +14,19 @@ import ( ) func init() { - task.Register("create_issue", func(svc *service.Services) task.Tasker { - return tasks.NewCreateIssueTask(svc) + task.Register("create_issue", func(app *service.App) task.Tasker { + return tasks.NewCreateIssueTask(app) }) - task.Register("coding_task", func(svc *service.Services) task.Tasker { - return tasks.NewCodingTask(svc) + task.Register("coding_task", func(app *service.App) task.Tasker { + return tasks.NewCodingTask(app) }) } -func initTasks(svc *service.Services) []task.Tasker { +func initTasks(app *service.App) []task.Tasker { var taskers []task.Tasker for _, name := range task.List() { - t, err := task.Get(name, svc) + t, err := task.Get(name, app) if err != nil { continue } @@ -36,7 +36,7 @@ func initTasks(svc *service.Services) []task.Tasker { return taskers } -func initializeServices(ctx context.Context) (*service.Services, func(), error) { +func initializeServices(ctx context.Context) (*service.App, func(), error) { return service.NewServices(ctx, tasks.BaseConfig()) } diff --git a/cmd/survey/tasks/base.go b/cmd/survey/tasks/base.go index 1550bd9..ccc8bbe 100644 --- a/cmd/survey/tasks/base.go +++ b/cmd/survey/tasks/base.go @@ -38,17 +38,12 @@ func BaseDetails() taskui.TaskDetails { } } -func BaseConfig() task.TaskConfig { - return task.TaskConfig{ - IssuePrefix: "pm", - WebAddress: ":8080", - BeadsDBPath: "./.pm/db.db", - StatisticsStoragePath: "./.pm/stats.json", - } +func BaseConfig() task.Config { + return service.BaseConfig } -func ClearIssues(svc *service.Services) error { - return svc.Beads.DeleteIssues() +func ClearIssues(app *service.App) error { + return app.Issues.DeleteIssues() } func BaseQuestions(interfaceType task.InterfaceType) taskui.Questions { diff --git a/cmd/survey/tasks/codingTask.go b/cmd/survey/tasks/codingTask.go index 9d3110d..520e7ef 100644 --- a/cmd/survey/tasks/codingTask.go +++ b/cmd/survey/tasks/codingTask.go @@ -23,14 +23,14 @@ Please write your code below this line! ` type CodingTask struct { - svc *service.Services + app *service.App } -func NewCodingTask(svc *service.Services) *CodingTask { - return &CodingTask{svc: svc} +func NewCodingTask(app *service.App) *CodingTask { + return &CodingTask{app: app} } -func (t *CodingTask) Config() task.TaskConfig { +func (t *CodingTask) Config() task.Config { return BaseConfig().WithStatisticsStoragePath("./.pm/coding-task-stats.json") } @@ -59,7 +59,7 @@ func (t *CodingTask) Questions(interfaceType task.InterfaceType) (questions task } func (t *CodingTask) Setup(ctx context.Context) error { - if err := ClearIssues(t.svc); err != nil { + if err := ClearIssues(t.app); err != nil { return err } @@ -71,7 +71,6 @@ func (t *CodingTask) Setup(ctx context.Context) error { } func (t *CodingTask) Validate(ctx context.Context) (bool, error) { - file, err := os.ReadFile("./code.txt") if err != nil { return false, err diff --git a/cmd/survey/tasks/createIssue.go b/cmd/survey/tasks/createIssue.go index f6552d1..ee96e55 100644 --- a/cmd/survey/tasks/createIssue.go +++ b/cmd/survey/tasks/createIssue.go @@ -17,14 +17,14 @@ Assign this task to yourself and start creating the issue. Make sure to fill out all the necessary details, including the title, description, and assignee.` type CreateIssueTask struct { - svc *service.Services + app *service.App } -func NewCreateIssueTask(svc *service.Services) *CreateIssueTask { - return &CreateIssueTask{svc: svc} +func NewCreateIssueTask(app *service.App) *CreateIssueTask { + return &CreateIssueTask{app: app} } -func (t *CreateIssueTask) Config() task.TaskConfig { +func (t *CreateIssueTask) Config() task.Config { return BaseConfig().WithStatisticsStoragePath("./.pm/create-issue-stats.json") } @@ -38,18 +38,18 @@ func (t *CreateIssueTask) Questions(interfaceType task.InterfaceType) taskui.Que func (t *CreateIssueTask) Setup(ctx context.Context) error { // Clear existing issues to ensure a clean state for the task - if err := ClearIssues(t.svc); err != nil { + if err := ClearIssues(t.app); err != nil { return err } issue := models.NewBaseIssue(). WithTitle("Create a New Issue").WithDescription(description).Build() - return t.svc.Beads.CreateIssue(ctx, &issue, "") + return t.app.Issues.CreateIssue(ctx, &issue, "") } func (t *CreateIssueTask) Validate(ctx context.Context) (bool, error) { - issues, err := t.svc.Beads.SearchIssues(ctx, "", models.IssueFilter{}) + issues, err := t.app.Issues.SearchIssues(ctx, "", models.IssueFilter{}) if err != nil { return false, err } diff --git a/cmd/tui/main.go b/cmd/tui/main.go index 824e6aa..b381675 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -3,19 +3,12 @@ package main import ( "context" + "github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/pkg/tui" ) func main() { - config := tui.TUIConfig{ - StatisticsStoragePath: "./.pm/stats.json", - BeadsDBPath: "./.pm/db.db", - IssuePrefix: "pm", - } - - tui := tui.NewTui() - - if err := tui.Run(context.Background(), config); err != nil { - panic(err) + if err := tui.NewTui().Run(context.Background(), service.BaseConfig); err != nil { + return } } diff --git a/cmd/web/main.go b/cmd/web/main.go index 1cb7518..5bdc103 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -2,25 +2,13 @@ package main import ( "context" - "fmt" - "os" "github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/pkg/web" ) func main() { - web := web.NewWeb() - - config := service.Config{ - WebAddress: "localhost:8080", - BeadsDBPath: "./.pm/db.db", - IssuePrefix: "pm", - StatisticsStoragePath: "./.pm/stats.json", - } - - if err := web.Run(context.Background(), config); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + if err := web.NewWeb().Run(context.Background(), service.BaseConfig); err != nil { + return } } diff --git a/internal/models/issue.go b/internal/models/issue.go index e751bfa..813f6ac 100644 --- a/internal/models/issue.go +++ b/internal/models/issue.go @@ -18,8 +18,9 @@ func NewBaseIssue() *IssueBuilder { WithID("pm-abc"). WithTitle("Basic Issue"). WithDescription("Basic Description"). - WithIssueType(TypeTask). - WithStatus(StatusOpen) + WithStatus(StatusOpen). + WithIssueType(TypeTask) + } func (b *IssueBuilder) WithID(id string) *IssueBuilder { diff --git a/internal/service/config.go b/internal/service/config.go index 7532ab5..c5cc627 100644 --- a/internal/service/config.go +++ b/internal/service/config.go @@ -8,6 +8,14 @@ type Config struct { StatisticsStoragePath string } +var BaseConfig = Config{ + RootCmd: "pm", + IssuePrefix: "pm", + WebAddress: ":8080", + BeadsDBPath: "./.pm/db.db", + StatisticsStoragePath: "./.pm/stats.json", +} + func (c Config) WithRootCmd(rootCmd string) Config { c.RootCmd = rootCmd return c diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go new file mode 100644 index 0000000..33095ba --- /dev/null +++ b/internal/service/interfaces.go @@ -0,0 +1,28 @@ +package service + +import ( + "context" + "log/slog" + + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/steveyegge/beads" +) + +type App struct { + Config Config + Issues IssueService + Stats StatsService + Logger *slog.Logger +} + +type IssueService interface { + beads.Storage + AllIssues(ctx context.Context) ([]models.Issue, error) + DeleteIssues() error +} + +type StatsService interface { + Load(ctx context.Context) error + Save(ctx context.Context) error + GetStatistics() (models.Statistics, error) +} diff --git a/internal/service/service.go b/internal/service/service.go index 009f3a5..72d72d1 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -2,8 +2,8 @@ package service import ( "context" - "database/sql" "fmt" + "log/slog" "os" "time" @@ -15,14 +15,7 @@ import ( "github.com/steveyegge/beads" ) -type Services struct { - Config Config - DB *sql.DB - Beads *BeadsService - Statistics *StatisticsService -} - -func NewServices(ctx context.Context, config Config) (*Services, func(), error) { +func NewServices(ctx context.Context, config Config) (*App, func(), error) { var cleanupFuncs []func() if !initialized(config.BeadsDBPath) { @@ -47,16 +40,18 @@ func NewServices(ctx context.Context, config Config) (*Services, func(), error) StartTime: time.Now(), }) + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + statSvc, err := NewStatisticsService(statStore) if err != nil { return nil, nil, err } - return &Services{ - DB: beadsSvc.UnderlyingDB(), - Beads: beadsSvc, - Statistics: statSvc, - Config: config, + return &App{ + Issues: beadsSvc, + Stats: statSvc, + Config: config, + Logger: logger, }, func() { runCleanup(cleanupFuncs) }, nil } diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index 27dd22a..420e37d 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -8,8 +8,8 @@ import ( "github.com/LazyBachelor/LazyPM/pkg/cli/commands" ) -// CLIConfig is an alias for service.Config, used to configure the CLI. -type CLIConfig = service.Config +// Config is an alias for service.Config, used to configure the CLI. +type Config = service.Config type CLI struct{} @@ -18,15 +18,15 @@ func NewCli() *CLI { } // Run initializes the services and executes the CLI commands. -func (c *CLI) Run(ctx context.Context, config CLIConfig) error { - svc, cleanup, err := service.NewServices(ctx, config) +func (c *CLI) Run(ctx context.Context, config Config) error { + app, cleanup, err := service.NewServices(ctx, config) if err != nil { return err } defer cleanup() - commands.SetServices(svc) + commands.SetApp(app) if err := commands.Execute(); err != nil { return err @@ -36,15 +36,15 @@ func (c *CLI) Run(ctx context.Context, config CLIConfig) error { } // RunWithArgs initializes the services and executes the CLI commands with the provided arguments. -func (c *CLI) RunWithArgs(ctx context.Context, config CLIConfig, args []string) error { - svc, cleanup, err := service.NewServices(ctx, config) +func (c *CLI) RunWithArgs(ctx context.Context, config Config, args []string) error { + app, cleanup, err := service.NewServices(ctx, config) if err != nil { return err } defer cleanup() - commands.SetServices(svc) + commands.SetApp(app) if err := commands.ExecuteArgs(args); err != nil { return err diff --git a/pkg/cli/commands/close.go b/pkg/cli/commands/close.go index b4ff882..2bafcbb 100644 --- a/pkg/cli/commands/close.go +++ b/pkg/cli/commands/close.go @@ -30,8 +30,10 @@ func runCloseCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("issue ID cannot be empty") } + app := AppFromContext(cmd.Context()) + // Fetch the issue to ensure it exists before closing. - issue, err := svc.Beads.GetIssue(cmd.Context(), closeID) + issue, err := app.Issues.GetIssue(cmd.Context(), closeID) if err != nil { return fmt.Errorf("error fetching issue: %w", err) } @@ -47,7 +49,7 @@ func runCloseCmd(cmd *cobra.Command, args []string) error { } // Close the issue. - err = svc.Beads.CloseIssue(cmd.Context(), closeID, issue.CloseReason, "", "") + err = app.Issues.CloseIssue(cmd.Context(), closeID, issue.CloseReason, "", "") if err != nil { return fmt.Errorf("error closing issue: %w", err) } diff --git a/pkg/cli/commands/completion.go b/pkg/cli/commands/completion.go index 77d3fcf..e164585 100644 --- a/pkg/cli/commands/completion.go +++ b/pkg/cli/commands/completion.go @@ -34,11 +34,12 @@ func completeIssues(cmd *cobra.Command, args []string, toComplete string) ([]str // GetIssueCompletions fetches issues matching the toComplete string for shell completion. func GetIssueCompletions(ctx context.Context, toComplete string) ([]models.Issue, cobra.ShellCompDirective) { - if svc == nil { + app := AppFromContext(ctx) + if app == nil { return nil, cobra.ShellCompDirectiveNoFileComp } - issues, err := svc.Beads.AllIssues(ctx) + issues, err := app.Issues.AllIssues(ctx) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/pkg/cli/commands/create.go b/pkg/cli/commands/create.go index 538ed27..a270f03 100644 --- a/pkg/cli/commands/create.go +++ b/pkg/cli/commands/create.go @@ -54,7 +54,8 @@ func runCreateCmd(cmd *cobra.Command, args []string) error { } // Create the issue using the service layer. - err := svc.Beads.CreateIssue(cmd.Context(), issue, "test_actor") + app := AppFromContext(cmd.Context()) + err := app.Issues.CreateIssue(cmd.Context(), issue, "test_actor") if err != nil { return fmt.Errorf("error creating issue: %w", err) } diff --git a/pkg/cli/commands/delete.go b/pkg/cli/commands/delete.go index b96e659..0b66590 100644 --- a/pkg/cli/commands/delete.go +++ b/pkg/cli/commands/delete.go @@ -46,8 +46,10 @@ func runDeleteCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("issue ID cannot be empty") } + app := AppFromContext(cmd.Context()) + // Fetch the issue to ensure it exists before deletion. - issue, err := svc.Beads.GetIssue(cmd.Context(), deleteID) + issue, err := app.Issues.GetIssue(cmd.Context(), deleteID) if err != nil { return fmt.Errorf("error fetching issue: %w", err) } @@ -70,7 +72,7 @@ func runDeleteCmd(cmd *cobra.Command, args []string) error { } // Delete the issue. - err = svc.Beads.DeleteIssue(cmd.Context(), deleteID) + err = app.Issues.DeleteIssue(cmd.Context(), deleteID) if err != nil { return fmt.Errorf("error deleting issue: %w", err) } @@ -83,9 +85,10 @@ func runDeleteCmd(cmd *cobra.Command, args []string) error { // runDeleteInteractive runs the interactive mode for deleting issues, // allowing users to select multiple issues for deletion. func runDeleteInteractive(ctx context.Context) error { + app := AppFromContext(ctx) options := []huh.Option[string]{} - issues, err := svc.Beads.SearchIssues(ctx, "", models.IssueFilter{}) + issues, err := app.Issues.SearchIssues(ctx, "", models.IssueFilter{}) if err != nil { return fmt.Errorf("error fetching issues: %w", err) } @@ -110,7 +113,7 @@ func runDeleteInteractive(ctx context.Context) error { } for _, id := range deleteIDs { - err := svc.Beads.DeleteIssue(ctx, id) + err := app.Issues.DeleteIssue(ctx, id) if err != nil { return fmt.Errorf("error deleting issue with ID %s: %w", id, err) } diff --git a/pkg/cli/commands/ls.go b/pkg/cli/commands/ls.go index bd65899..1eb13cc 100644 --- a/pkg/cli/commands/ls.go +++ b/pkg/cli/commands/ls.go @@ -55,7 +55,8 @@ func runGetIssuesCmd(cmd *cobra.Command, args []string) error { } // Fetch issues based on the search query and filters. - issuesPtr, err := svc.Beads.SearchIssues(cmd.Context(), queryArg, filter) + app := AppFromContext(cmd.Context()) + issuesPtr, err := app.Issues.SearchIssues(cmd.Context(), queryArg, filter) if err != nil { return err } diff --git a/pkg/cli/commands/read.go b/pkg/cli/commands/read.go index 4ea6a54..40c8c8c 100644 --- a/pkg/cli/commands/read.go +++ b/pkg/cli/commands/read.go @@ -24,7 +24,8 @@ func runGetCmd(cmd *cobra.Command, args []string) error { issueID := args[0] // Fetch the issue details using the service layer. - issuePtr, err := svc.Beads.GetIssue(cmd.Context(), issueID) + app := AppFromContext(cmd.Context()) + issuePtr, err := app.Issues.GetIssue(cmd.Context(), issueID) if err != nil { return err } diff --git a/pkg/cli/commands/root.go b/pkg/cli/commands/root.go index 9e18856..45b1c45 100644 --- a/pkg/cli/commands/root.go +++ b/pkg/cli/commands/root.go @@ -10,9 +10,12 @@ import ( "github.com/spf13/cobra" ) -// svc is a global variable that holds beads, config and stats services. -// Must be called before executing any commands to ensure services are available. -var svc *service.Services +type contextKey string + +const appKey contextKey = "app" + +// app is a package-level variable used during command setup +var app *service.App // Flags struct to hold command-line flag values for issues. type Flags struct { @@ -30,13 +33,28 @@ type Flags struct { var rootCmd = &cobra.Command{ Short: "Project Management CLI", Long: `Project Management CLI for managing issues and tasks.`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Inject app into context for all commands + if app != nil { + cmd.SetContext(context.WithValue(cmd.Context(), appKey, app)) + } + }, } -// SetServices sets the global services variable for use in command execution. +// SetApp sets the app variable for use in command execution. // Must be called before executing any commands to ensure services are available. -func SetServices(services *service.Services) { - svc = services - rootCmd.Use = svc.Config.RootCmd +func SetApp(application *service.App) { + app = application + rootCmd.Use = app.Config.RootCmd +} + +// AppFromContext retrieves the App from the command context +func AppFromContext(ctx context.Context) *service.App { + if a, ok := ctx.Value(appKey).(*service.App); ok { + return a + } + // Fallback to package-level app (for testing or edge cases) + return app } // Execute executes the root command using the fang library. diff --git a/pkg/cli/commands/update.go b/pkg/cli/commands/update.go index 099a08c..f26b23e 100644 --- a/pkg/cli/commands/update.go +++ b/pkg/cli/commands/update.go @@ -23,7 +23,9 @@ var updateCmd = &cobra.Command{ func runUpdateCmd(cmd *cobra.Command, args []string) error { issueID := args[0] - issue, err := svc.Beads.GetIssue(cmd.Context(), issueID) + app := AppFromContext(cmd.Context()) + + issue, err := app.Issues.GetIssue(cmd.Context(), issueID) if err != nil { return fmt.Errorf("error getting issue: %w", err) } @@ -37,12 +39,12 @@ func runUpdateCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("error getting update values: %w", err) } - err = svc.Beads.UpdateIssue(cmd.Context(), issueID, updates, "test_actor") + err = app.Issues.UpdateIssue(cmd.Context(), issueID, updates, "test_actor") if err != nil { return fmt.Errorf("error updating issue: %w", err) } - updatedIssue, err := svc.Beads.GetIssue(cmd.Context(), issueID) + updatedIssue, err := app.Issues.GetIssue(cmd.Context(), issueID) if err != nil { return fmt.Errorf("error getting updated issue: %w", err) } diff --git a/pkg/cli/repl/repl.go b/pkg/cli/repl/repl.go index 1133586..ff49153 100644 --- a/pkg/cli/repl/repl.go +++ b/pkg/cli/repl/repl.go @@ -37,7 +37,7 @@ func NewRepl() *REPL { } // Run starts the interactive Read-Eval-Print Loop for the PM CLI. -func (r *REPL) Run(ctx context.Context, config cli.CLIConfig) error { +func (r *REPL) Run(ctx context.Context, config cli.Config) error { // Set terminal to raw mode to capture input properly in the REPL. // This allows us to handle input character by character and provide a better user experience. // We also ensure that the terminal state is restored when the REPL exits, even if an error occurs. @@ -48,14 +48,14 @@ func (r *REPL) Run(ctx context.Context, config cli.CLIConfig) error { defer term.Restore(int(os.Stdin.Fd()), oldState) // Initialize services for beads, config and stats. - svc, cleanup, err := service.NewServices(ctx, config) + app, cleanup, err := service.NewServices(ctx, config) if err != nil { return fmt.Errorf("failed to initialize services: %w", err) } defer cleanup() - // Make sure to set services, to ensure they are available. - commands.SetServices(svc) + // Make sure to set app, to ensure they are available. + commands.SetApp(app) // Set the REPL instance so status command can access it commands.SetRepl(r) diff --git a/pkg/task/register.go b/pkg/task/register.go index 7fda855..4f57ae4 100644 --- a/pkg/task/register.go +++ b/pkg/task/register.go @@ -6,21 +6,21 @@ import ( "github.com/LazyBachelor/LazyPM/internal/service" ) -var registry = make(map[string]func(*service.Services) Tasker) +var registry = make(map[string]func(*service.App) Tasker) -func Register(name string, constructor func(*service.Services) Tasker) { +func Register(name string, constructor func(*service.App) Tasker) { if _, exists := registry[name]; exists { panic(fmt.Sprintf("task %q already registered", name)) } registry[name] = constructor } -func Get(name string, svc *service.Services) (Tasker, error) { +func Get(name string, app *service.App) (Tasker, error) { constructor, ok := registry[name] if !ok { return nil, fmt.Errorf("task %q not found", name) } - return constructor(svc), nil + return constructor(app), nil } func List() []string { diff --git a/pkg/task/runner.go b/pkg/task/runner.go index 37246f9..d060cdd 100644 --- a/pkg/task/runner.go +++ b/pkg/task/runner.go @@ -94,14 +94,13 @@ func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan Valida feedbackChan <- feedback doneChan <- true return - } else { - if err != nil { - feedback.Message = err.Error() - } else { - feedback.Message = "Task not yet complete" - } - feedbackChan <- feedback } + if err != nil { + feedback.Message = err.Error() + } else { + feedback.Message = "Task not yet complete" + } + feedbackChan <- feedback case <-quitChan: return case <-ctx.Done(): diff --git a/pkg/task/types.go b/pkg/task/types.go index abffe0e..085d3bb 100644 --- a/pkg/task/types.go +++ b/pkg/task/types.go @@ -8,15 +8,15 @@ import ( taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui" ) -type TaskConfig = service.Config +type Config = service.Config type InterfaceType string type Interface interface { - Run(context.Context, TaskConfig) error + Run(context.Context, Config) error } type Tasker interface { - Config() TaskConfig + Config() Config Details() taskui.TaskDetails Questions(InterfaceType) taskui.Questions Setup(context.Context) error diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 2c8f501..25c08c8 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -9,7 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -type TUIConfig = service.Config +type Config = service.Config type Tui struct { feedbackChan chan task.ValidationFeedback @@ -20,15 +20,15 @@ func NewTui() *Tui { return &Tui{} } -func (t *Tui) Run(ctx context.Context, config TUIConfig) error { - svc, cleanup, err := service.NewServices(ctx, config) +func (t *Tui) Run(ctx context.Context, config Config) error { + app, cleanup, err := service.NewServices(ctx, config) if err != nil { return err } defer cleanup() - p := tea.NewProgram(views.NewDashboardView(svc, t.feedbackChan, t.quitChan), + p := tea.NewProgram(views.NewDashboardView(app, t.feedbackChan, t.quitChan), tea.WithAltScreen(), tea.WithMouseAllMotion()) if t.quitChan != nil { diff --git a/pkg/tui/views/dashboard/issue_list.go b/pkg/tui/views/dashboard/issue_list.go index 2ea4d80..f3f5dbc 100644 --- a/pkg/tui/views/dashboard/issue_list.go +++ b/pkg/tui/views/dashboard/issue_list.go @@ -15,7 +15,7 @@ import ( type IssueList struct { list list.Model - svc *service.Services + app *service.App width int height int } @@ -74,8 +74,8 @@ func renderHeaders(cols []TableColumn) string { return lipgloss.JoinHorizontal(lipgloss.Left, parts...) } -func NewIssueList(svc *service.Services, width, height int) IssueList { - issues, err := svc.Beads.AllIssues(context.Background()) +func NewIssueList(app *service.App, width, height int) IssueList { + issues, err := app.Issues.AllIssues(context.Background()) if err != nil { return IssueList{} } @@ -102,13 +102,13 @@ func NewIssueList(svc *service.Services, width, height int) IssueList { return IssueList{ list: l, - svc: svc, + app: app, width: width, height: height, } } -func NewIssueListFromIssues(svc *service.Services, issues []models.Issue, width, height int) IssueList { +func NewIssueListFromIssues(app *service.App, issues []models.Issue, width, height int) IssueList { // for making an IssueList from a pre-existing list of issues. listIssues := make([]list.Item, len(issues)) for i, issue := range issues { @@ -125,7 +125,7 @@ func NewIssueListFromIssues(svc *service.Services, issues []models.Issue, width, l.FilterInput.Prompt = "🔍 " return IssueList{ list: l, - svc: svc, + app: app, width: width, height: height, } diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index 6a925b6..65fc34a 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -15,21 +15,21 @@ type ValidationFeedbackMsg struct { } type Model struct { - header Header - issueList IssueList - issueDetail IssueDetail - closedIssueList IssueList - helpBar HelpBar - keyMap DashboardKeyMap - svc *service.Services - width int - height int - focusedWindow int // 0 = main (display issues), 1 = closed issues - focusedPaneMain int // 0 = list, 1 = detail + header Header + issueList IssueList + issueDetail IssueDetail + closedIssueList IssueList + helpBar HelpBar + keyMap DashboardKeyMap + app *service.App + width int + height int + focusedWindow int // 0 = main (display issues), 1 = closed issues + focusedPaneMain int // 0 = list, 1 = detail focusedPaneClosed int - editingTitle bool // true while we are editing a title - titleInput textinput.Model - editingIssueID string + editingTitle bool // true while we are editing a title + titleInput textinput.Model + editingIssueID string editingDescription bool // true while editing a description descriptionInput textarea.Model @@ -41,32 +41,32 @@ type Model struct { deleteConfirmID string deleteConfirmIndex int - choosingStatus bool - statusIssueID string + choosingStatus bool + statusIssueID string feedbackChan chan task.ValidationFeedback quitChan chan bool currentFeedback task.ValidationFeedback showComplete bool } -func NewDashboard(svc *service.Services, feedbackChan chan task.ValidationFeedback, quitChan chan bool) *Model { +func NewDashboard(app *service.App, feedbackChan chan task.ValidationFeedback, quitChan chan bool) *Model { m := &Model{ header: NewHeader("Project Manager Dashboard"), keyMap: defaultDashboardKeyMap, - svc: svc, + app: app, width: 80, height: 24, - focusedWindow: 0, - focusedPaneMain: 0, - focusedPaneClosed: 0, - feedbackChan: feedbackChan, - quitChan: quitChan, + focusedWindow: 0, + focusedPaneMain: 0, + focusedPaneClosed: 0, + feedbackChan: feedbackChan, + quitChan: quitChan, } - allIssues, _ := svc.Beads.AllIssues(context.Background()) - m.issueList = NewIssueListFromIssues(svc, OpenAndInProgressOnly(allIssues), 0, 0) + allIssues, _ := app.Issues.AllIssues(context.Background()) + m.issueList = NewIssueListFromIssues(app, OpenAndInProgressOnly(allIssues), 0, 0) m.issueDetail = NewIssueDetail() - m.closedIssueList = NewIssueListFromIssues(svc, ClosedOnly(allIssues), 0, 0) + m.closedIssueList = NewIssueListFromIssues(app, ClosedOnly(allIssues), 0, 0) m.helpBar = NewHelpBar(m.keyMap) ti := textinput.New() diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index 47e9387..a0a3d5d 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -39,45 +39,45 @@ type issueDeletedMsg struct { PreviousIndex int } -func updateIssueTitleCmd(svc *service.Services, issueID, newTitle string) tea.Cmd { +func updateIssueTitleCmd(app *service.App, issueID, newTitle string) tea.Cmd { return func() tea.Msg { updates := map[string]interface{}{"title": newTitle} - err := svc.Beads.UpdateIssue(context.Background(), issueID, updates, "tui") + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") return issueTitleUpdatedMsg{IssueID: issueID, Err: err} } } -func updateIssueDescriptionCmd(svc *service.Services, issueID, newDescription string) tea.Cmd { +func updateIssueDescriptionCmd(app *service.App, issueID, newDescription string) tea.Cmd { return func() tea.Msg { updates := map[string]interface{}{"description": newDescription} - err := svc.Beads.UpdateIssue(context.Background(), issueID, updates, "tui") + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") return issueDescriptionUpdatedMsg{IssueID: issueID, Err: err} } } -func updateIssueStatusCmd(svc *service.Services, issueID, status string) tea.Cmd { +func updateIssueStatusCmd(app *service.App, issueID, status string) tea.Cmd { return func() tea.Msg { updates := map[string]interface{}{"status": status} - err := svc.Beads.UpdateIssue(context.Background(), issueID, updates, "tui") + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") return issueStatusUpdatedMsg{IssueID: issueID, Err: err} } } -func createIssueCmd(svc *service.Services, title string) tea.Cmd { +func createIssueCmd(app *service.App, title string) tea.Cmd { return func() tea.Msg { issue := &models.Issue{ Title: title, Status: models.StatusOpen, IssueType: models.TypeTask, } - err := svc.Beads.CreateIssue(context.Background(), issue, "tui") + err := app.Issues.CreateIssue(context.Background(), issue, "tui") return issueCreatedMsg{Issue: issue, Err: err} } } -func deleteIssueCmd(svc *service.Services, issueID string, currentIndex int) tea.Cmd { +func deleteIssueCmd(app *service.App, issueID string, currentIndex int) tea.Cmd { return func() tea.Msg { - err := svc.Beads.DeleteIssue(context.Background(), issueID) + err := app.Issues.DeleteIssue(context.Background(), issueID) return issueDeletedMsg{IssueID: issueID, Err: err, PreviousIndex: currentIndex} } } @@ -91,7 +91,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { return m, nil } - issues, err := m.svc.Beads.AllIssues(context.Background()) + issues, err := m.app.Issues.AllIssues(context.Background()) if err != nil { return m, nil } @@ -112,7 +112,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { return m, nil } - issues, err := m.svc.Beads.AllIssues(context.Background()) + issues, err := m.app.Issues.AllIssues(context.Background()) if err != nil { return m, nil } @@ -132,7 +132,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { return m, nil } - issues, err := m.svc.Beads.AllIssues(context.Background()) + issues, err := m.app.Issues.AllIssues(context.Background()) if err != nil { return m, nil } @@ -158,7 +158,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil || msg.Issue == nil { return m, nil } - issues, err := m.svc.Beads.AllIssues(context.Background()) + issues, err := m.app.Issues.AllIssues(context.Background()) if err != nil { return m, nil } @@ -185,7 +185,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { return m, nil } - issues, err := m.svc.Beads.AllIssues(context.Background()) + issues, err := m.app.Issues.AllIssues(context.Background()) if err != nil { return m, nil } @@ -241,7 +241,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { idx := m.deleteConfirmIndex m.confirmingDelete = false m.deleteConfirmID = "" - return m, deleteIssueCmd(m.svc, issueID, idx) + return m, deleteIssueCmd(m.app, issueID, idx) case "n", "N", "esc": m.confirmingDelete = false m.deleteConfirmID = "" @@ -255,17 +255,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" - return m, updateIssueStatusCmd(m.svc, issueID, string(models.StatusOpen)) + return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusOpen)) case "i": issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" - return m, updateIssueStatusCmd(m.svc, issueID, string(models.StatusInProgress)) + return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) case "c": issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" - return m, updateIssueStatusCmd(m.svc, issueID, string(models.StatusClosed)) + return m, updateIssueStatusCmd(m.app, issueID, string(models.StatusClosed)) case "esc": m.choosingStatus = false m.statusIssueID = "" @@ -277,7 +277,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "enter" { title := m.createTitleInput.Value() if title != "" { - return m, createIssueCmd(m.svc, title) + return m, createIssueCmd(m.app, title) } } if msg.String() == "esc" { @@ -295,7 +295,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "enter" { newTitle := m.titleInput.Value() if newTitle != "" { - return m, updateIssueTitleCmd(m.svc, m.editingIssueID, newTitle) + return m, updateIssueTitleCmd(m.app, m.editingIssueID, newTitle) } } if msg.String() == "esc" { @@ -316,7 +316,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editingDescription = false m.editingDescIssueID = "" m.descriptionInput.Blur() - return m, updateIssueDescriptionCmd(m.svc, issueID, newDesc) + return m, updateIssueDescriptionCmd(m.app, issueID, newDesc) } if msg.String() == "esc" { m.editingDescription = false diff --git a/pkg/tui/views/views.go b/pkg/tui/views/views.go index 04bbea6..f60f508 100644 --- a/pkg/tui/views/views.go +++ b/pkg/tui/views/views.go @@ -6,6 +6,6 @@ import ( "github.com/LazyBachelor/LazyPM/pkg/tui/views/dashboard" ) -func NewDashboardView(svc *service.Services, feedbackChan chan task.ValidationFeedback, quitChan chan bool) *dashboard.Model { - return dashboard.NewDashboard(svc, feedbackChan, quitChan) +func NewDashboardView(app *service.App, feedbackChan chan task.ValidationFeedback, quitChan chan bool) *dashboard.Model { + return dashboard.NewDashboard(app, feedbackChan, quitChan) } diff --git a/pkg/web/handler/comments.go b/pkg/web/handler/comments.go index 8c5db6d..043ea2c 100644 --- a/pkg/web/handler/comments.go +++ b/pkg/web/handler/comments.go @@ -29,7 +29,7 @@ func ListComments(w http.ResponseWriter, r *http.Request) { func CreateComment(w http.ResponseWriter, r *http.Request) { issue := r.Context().Value(issueKey).(*models.Issue) - svc := Services(r) + app := App(r) hx := HTMX(r) form, err := ParseForm[CommentForm](r) @@ -47,7 +47,7 @@ func CreateComment(w http.ResponseWriter, r *http.Request) { return } - comment, err := svc.Beads.AddIssueComment(r.Context(), issue.ID, form.Author, form.Text) + comment, err := app.Issues.AddIssueComment(r.Context(), issue.ID, form.Author, form.Text) if err != nil { http.Error(w, "Failed to create comment: "+err.Error(), http.StatusInternalServerError) return diff --git a/pkg/web/handler/context.go b/pkg/web/handler/context.go index bdb1b51..a7631ed 100644 --- a/pkg/web/handler/context.go +++ b/pkg/web/handler/context.go @@ -11,14 +11,14 @@ import ( type contextKey string const ( - servicesKey contextKey = "services" - htmxKey contextKey = "htmx" + appKey contextKey = "app" + htmxKey contextKey = "htmx" ) -func ServicesMiddleware(svc *service.Services) func(http.Handler) http.Handler { +func AppMiddleware(app *service.App) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := context.WithValue(r.Context(), servicesKey, svc) + ctx := context.WithValue(r.Context(), appKey, app) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -33,8 +33,8 @@ func HTMXMiddleware(next http.Handler) http.Handler { }) } -func Services(r *http.Request) *service.Services { - return r.Context().Value(servicesKey).(*service.Services) +func App(r *http.Request) *service.App { + return r.Context().Value(appKey).(*service.App) } func HTMX(r *http.Request) *htmx.Handler { diff --git a/pkg/web/handler/index.go b/pkg/web/handler/index.go index 677a033..98e559b 100644 --- a/pkg/web/handler/index.go +++ b/pkg/web/handler/index.go @@ -8,10 +8,10 @@ import ( ) func IndexHandler(w http.ResponseWriter, r *http.Request) { - svc := Services(r) + app := App(r) hx := HTMX(r) - issues, err := svc.Beads.AllIssues(r.Context()) + issues, err := app.Issues.AllIssues(r.Context()) if err != nil { http.Error(w, "failed to retrieve issues", http.StatusInternalServerError) return diff --git a/pkg/web/handler/issues.go b/pkg/web/handler/issues.go index d0bb77a..b9ceace 100644 --- a/pkg/web/handler/issues.go +++ b/pkg/web/handler/issues.go @@ -30,7 +30,7 @@ type UpdateIssueForm struct { } func CreateIssue(w http.ResponseWriter, r *http.Request) { - svc := Services(r) + app := App(r) hx := HTMX(r) form, err := ParseForm[IssueForm](r) @@ -49,7 +49,7 @@ func CreateIssue(w http.ResponseWriter, r *http.Request) { } issue := form.toIssue() - if err := svc.Beads.CreateIssue(r.Context(), &issue, ""); err != nil { + if err := app.Issues.CreateIssue(r.Context(), &issue, ""); err != nil { http.Error(w, "Failed to create issue: "+err.Error(), http.StatusInternalServerError) return } @@ -64,10 +64,10 @@ func CreateIssue(w http.ResponseWriter, r *http.Request) { } func ListIssues(w http.ResponseWriter, r *http.Request) { - svc := Services(r) + app := App(r) hx := HTMX(r) - issues, err := svc.Beads.AllIssues(r.Context()) + issues, err := app.Issues.AllIssues(r.Context()) if err != nil { http.Error(w, "Failed to retrieve issues", http.StatusInternalServerError) return @@ -79,10 +79,10 @@ func ListIssues(w http.ResponseWriter, r *http.Request) { func IssueCtx(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - svc := Services(r) + app := App(r) id := chi.URLParam(r, "id") - issue, err := svc.Beads.GetIssue(r.Context(), id) + issue, err := app.Issues.GetIssue(r.Context(), id) if err != nil { http.Error(w, "Error getting issue: "+err.Error(), http.StatusNotFound) return @@ -92,7 +92,7 @@ func IssueCtx(next http.Handler) http.Handler { return } - comments, err := svc.Beads.GetIssueComments(r.Context(), issue.ID) + comments, err := app.Issues.GetIssueComments(r.Context(), issue.ID) if err != nil { http.Error(w, "Error getting comments: "+err.Error(), http.StatusInternalServerError) return @@ -132,7 +132,7 @@ func GetIssue(w http.ResponseWriter, r *http.Request) { func UpdateIssue(w http.ResponseWriter, r *http.Request) { issue := r.Context().Value(issueKey).(*models.Issue) - svc := Services(r) + app := App(r) hx := HTMX(r) form, err := ParseForm[UpdateIssueForm](r) @@ -152,12 +152,12 @@ func UpdateIssue(w http.ResponseWriter, r *http.Request) { changes := form.toChanges() - if err := svc.Beads.UpdateIssue(r.Context(), issue.ID, changes, ""); err != nil { + if err := app.Issues.UpdateIssue(r.Context(), issue.ID, changes, ""); err != nil { http.Error(w, "Failed to update issue", http.StatusInternalServerError) return } - issue, err = svc.Beads.GetIssue(r.Context(), issue.ID) + issue, err = app.Issues.GetIssue(r.Context(), issue.ID) if err != nil { http.Error(w, "Failed to retrieve updated issue", http.StatusInternalServerError) return @@ -170,8 +170,8 @@ func UpdateIssue(w http.ResponseWriter, r *http.Request) { func DeleteIssue(w http.ResponseWriter, r *http.Request) { issue := r.Context().Value(issueKey).(*models.Issue) - svc := Services(r) - if err := svc.Beads.DeleteIssue(r.Context(), issue.ID); err != nil { + app := App(r) + if err := app.Issues.DeleteIssue(r.Context(), issue.ID); err != nil { http.Error(w, "Failed to delete issue", http.StatusInternalServerError) return } diff --git a/pkg/web/server/routes.go b/pkg/web/server/routes.go index 1f69bc7..9a2169c 100644 --- a/pkg/web/server/routes.go +++ b/pkg/web/server/routes.go @@ -22,7 +22,7 @@ func (s *Server) RegisterRoutes(assets embed.FS) http.Handler { r.Use(middleware.CleanPath) r.Use(handler.HTMXMiddleware) - r.Use(handler.ServicesMiddleware(s.Services)) + r.Use(handler.AppMiddleware(s.App)) s.handleAssets(r, assets) diff --git a/pkg/web/server/server.go b/pkg/web/server/server.go index da06a1b..7931c5a 100644 --- a/pkg/web/server/server.go +++ b/pkg/web/server/server.go @@ -9,9 +9,9 @@ import ( ) type Server struct { - Address string - Assets embed.FS - Services *service.Services + Address string + Assets embed.FS + App *service.App } // NewServer creates and configures a new HTTP server instance. diff --git a/pkg/web/web.go b/pkg/web/web.go index f9ad4b3..896d3d6 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -3,6 +3,7 @@ package web import ( "context" "embed" + "errors" "fmt" "net/http" "time" @@ -13,7 +14,7 @@ import ( "github.com/LazyBachelor/LazyPM/pkg/web/server" ) -type WebConfig = service.Config +type Config = service.Config type Web struct { feedbackChan chan task.ValidationFeedback @@ -27,8 +28,8 @@ func NewWeb() *Web { //go:embed assets/* var assets embed.FS -func (w *Web) Run(ctx context.Context, config WebConfig) error { - svc, cleanup, err := service.NewServices(ctx, config) +func (w *Web) Run(ctx context.Context, config Config) error { + app, cleanup, err := service.NewServices(ctx, config) if err != nil { return err } @@ -36,16 +37,16 @@ func (w *Web) Run(ctx context.Context, config WebConfig) error { defer cleanup() httpServer := server.NewServer(server.Server{ - Address: config.WebAddress, - Assets: assets, - Services: svc, + Address: config.WebAddress, + Assets: assets, + App: app, }) fmt.Printf("Starting web server on %s...\n", config.WebAddress) serverErr := make(chan error, 1) go func() { - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { serverErr <- err } }() From 33c8c05ae3b7bedbd1144d1d854334e3f77a7408 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Sun, 22 Feb 2026 19:46:55 +0100 Subject: [PATCH 11/13] refactor to decouple commands form entrypoint for reusability and centralizing commands. Increase reusability and composition --- cmd/pm/main.go | 14 ++- cmd/survey/cmd.go | 98 ------------------- cmd/survey/main.go | 21 +++- cmd/survey/runner.go | 57 +++++++++-- cmd/survey/tasks/base.go | 2 +- .../commands/issues}/close.go | 11 +-- .../commands/issues}/completion.go | 2 +- .../commands/issues}/create.go | 24 +++-- .../commands/issues}/delete.go | 12 +-- .../ls.go => internal/commands/issues/list.go | 26 +++-- .../commands/issues}/read.go | 7 +- .../commands/issues}/root.go | 40 ++++---- .../commands/issues}/update.go | 22 ++--- internal/commands/survey/list.go | 17 ++++ internal/commands/survey/root.go | 29 ++++++ internal/commands/survey/start.go | 9 ++ internal/commands/survey/status.go | 30 ++++++ internal/commands/survey/submit.go | 12 +++ internal/service/interfaces.go | 15 ++- pkg/cli/cli.go | 23 +++-- pkg/cli/commands/status.go | 46 --------- pkg/cli/styles/styles.go | 10 -- pkg/{cli => }/repl/completer.go | 0 pkg/{cli => }/repl/executor.go | 10 +- pkg/{cli => }/repl/options.go | 0 pkg/{cli => }/repl/repl.go | 41 +++++--- pkg/{cli => }/repl/suggestions.go | 5 +- pkg/task/runner.go | 4 +- 28 files changed, 305 insertions(+), 282 deletions(-) delete mode 100644 cmd/survey/cmd.go rename {pkg/cli/commands => internal/commands/issues}/close.go (87%) rename {pkg/cli/commands => internal/commands/issues}/completion.go (99%) rename {pkg/cli/commands => internal/commands/issues}/create.go (82%) rename {pkg/cli/commands => internal/commands/issues}/delete.go (92%) rename pkg/cli/commands/ls.go => internal/commands/issues/list.go (63%) rename {pkg/cli/commands => internal/commands/issues}/read.go (88%) rename {pkg/cli/commands => internal/commands/issues}/root.go (69%) rename {pkg/cli/commands => internal/commands/issues}/update.go (80%) create mode 100644 internal/commands/survey/list.go create mode 100644 internal/commands/survey/root.go create mode 100644 internal/commands/survey/start.go create mode 100644 internal/commands/survey/status.go create mode 100644 internal/commands/survey/submit.go delete mode 100644 pkg/cli/commands/status.go delete mode 100644 pkg/cli/styles/styles.go rename pkg/{cli => }/repl/completer.go (100%) rename pkg/{cli => }/repl/executor.go (63%) rename pkg/{cli => }/repl/options.go (100%) rename pkg/{cli => }/repl/repl.go (74%) rename pkg/{cli => }/repl/suggestions.go (97%) diff --git a/cmd/pm/main.go b/cmd/pm/main.go index 3ac39fd..291d10b 100644 --- a/cmd/pm/main.go +++ b/cmd/pm/main.go @@ -3,12 +3,24 @@ package main import ( "context" + issuesCmd "github.com/LazyBachelor/LazyPM/internal/commands/issues" + surveyCmd "github.com/LazyBachelor/LazyPM/internal/commands/survey" "github.com/LazyBachelor/LazyPM/internal/service" "github.com/LazyBachelor/LazyPM/pkg/cli" ) func main() { - if err := cli.NewCli().Run(context.Background(), service.BaseConfig); err != nil { + if err := cli.NewCli(issuesCmd.RootCmd).Run(context.Background(), service.BaseConfig); err != nil { return } } + +func init() { + issuesCmd.RootCmd.AddCommand(issuesCmd.GetCmd) + issuesCmd.RootCmd.AddCommand(issuesCmd.ListCmd) + issuesCmd.RootCmd.AddCommand(issuesCmd.CloseCmd) + issuesCmd.RootCmd.AddCommand(issuesCmd.CreateCmd) + issuesCmd.RootCmd.AddCommand(issuesCmd.DeleteCmd) + issuesCmd.RootCmd.AddCommand(issuesCmd.UpdateCmd) + issuesCmd.RootCmd.AddCommand(surveyCmd.StatusCmd) +} diff --git a/cmd/survey/cmd.go b/cmd/survey/cmd.go deleted file mode 100644 index 6243cc1..0000000 --- a/cmd/survey/cmd.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/LazyBachelor/LazyPM/pkg/task" - "github.com/spf13/cobra" -) - -var interfaceType string -var stage int - -var rootCmd = &cobra.Command{ - Use: "survey", - Long: `Project Management Interface Survey - -Thank you for participating in our survey! - -We are gathering data on how users interact with different task management interfaces to better understand their preferences and compare their usability. -This survey will present you with a series of tasks to complete using various interfaces, including command-line, web-based, and terminal user interfaces. - -Please answer the questions honestly and to the best of your ability. -Your responses will be kept confidential and used solely for research purposes.`} - -var startCmd = &cobra.Command{ - Use: "start", - Short: "Start the user survey", - RunE: runStartCmd, -} - -var submitCmd = &cobra.Command{ - Use: "submit", - Short: "Submit your survey responses", - RunE: func(cmd *cobra.Command, args []string) error { - cmd.Println("Submitting responses and metrics...") - return nil - }, -} - -func runStartCmd(cmd *cobra.Command, args []string) error { - interfaces := initInterfaces() - - svc, cleanup, err := initializeServices(cmd.Context()) - if err != nil { - return returnIfUserQuit(err, "failed to initialize services") - } - defer cleanup() - - surveyTasks := initTasks(svc) - - if cmd.Flags().Changed("interface") { - if _, ok := interfaces[interfaceType]; !ok { - return fmt.Errorf("invalid interface, valid are (tui, repl, web)") - } - interfaces = map[string]task.Interface{ - interfaceType: interfaces[interfaceType], - } - } - - if cmd.Flags().Changed("stage") { - if stage < 1 || stage > len(surveyTasks) { - return fmt.Errorf("invalid stage") - } - if err := runTask(cmd.Context(), surveyTasks[stage-1], interfaces[interfaceType]); err != nil { - return err - } - return nil - } - - if err := newIntroModel().Run(); err != nil { - return returnIfUserQuit(err, "failed to run intro") - } - - if err := taskLoop(cmd.Context(), surveyTasks, interfaces); err != nil { - return returnIfUserQuit(err, "task loop failed") - } - return nil -} - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List available tasks", - RunE: func(cmd *cobra.Command, args []string) error { - for i, name := range task.List() { - cmd.Printf("%d. %s\n", i+1, name) - } - return nil - }, -} - -func init() { - rootCmd.CompletionOptions.DisableDefaultCmd = true - startCmd.Flags().StringVarP(&interfaceType, "interface", "i", "tui", "Specify interface.") - startCmd.Flags().IntVarP(&stage, "stage", "s", 1, "Run stage directly") - rootCmd.AddCommand(startCmd) - rootCmd.AddCommand(submitCmd) - rootCmd.AddCommand(listCmd) -} diff --git a/cmd/survey/main.go b/cmd/survey/main.go index 5ff186a..085b265 100644 --- a/cmd/survey/main.go +++ b/cmd/survey/main.go @@ -3,14 +3,33 @@ package main import ( "context" + "github.com/LazyBachelor/LazyPM/cmd/survey/tasks" + "github.com/LazyBachelor/LazyPM/internal/commands/survey" + "github.com/LazyBachelor/LazyPM/internal/service" + "github.com/LazyBachelor/LazyPM/pkg/task" "github.com/charmbracelet/fang" ) func main() { ctx := context.Background() - if err := fang.Execute(ctx, rootCmd, + if err := fang.Execute(ctx, surveyCmd.RootCmd, fang.WithColorSchemeFunc(fang.AnsiColorScheme)); err != nil { return } } + +func init() { + surveyCmd.StartCmd.RunE = runStartCmd + surveyCmd.RootCmd.AddCommand(surveyCmd.StartCmd) + surveyCmd.RootCmd.AddCommand(surveyCmd.SubmitCmd) + surveyCmd.RootCmd.AddCommand(surveyCmd.StatusCmd) + surveyCmd.RootCmd.AddCommand(surveyCmd.ListCmd) + + task.Register("create_issue", func(app *service.App) task.Tasker { + return tasks.NewCreateIssueTask(app) + }) + task.Register("coding_task", func(app *service.App) task.Tasker { + return tasks.NewCodingTask(app) + }) +} diff --git a/cmd/survey/runner.go b/cmd/survey/runner.go index 3e213f1..6ff6ca0 100644 --- a/cmd/survey/runner.go +++ b/cmd/survey/runner.go @@ -7,28 +7,67 @@ import ( "math/rand" "github.com/LazyBachelor/LazyPM/cmd/survey/tasks" + surveyCmd "github.com/LazyBachelor/LazyPM/internal/commands/survey" "github.com/LazyBachelor/LazyPM/pkg/task" + "github.com/spf13/cobra" ) -func runTask(ctx context.Context, t task.Tasker, i task.Interface) error { - return task.RunTask(ctx, t, i, tasks.InterfaceToType(i)) +func runStartCmd(cmd *cobra.Command, args []string) error { + interfaces := initInterfaces() + + svc, cleanup, err := initializeServices(cmd.Context()) + if err != nil { + return returnIfUserQuit(err, "failed to initialize services") + } + defer cleanup() + + surveyTasks := initTasks(svc) + + if cmd.Flags().Changed("interface") { + if _, ok := interfaces[surveyCmd.InterfaceType]; !ok { + return fmt.Errorf("invalid interface, valid are (tui, repl, web)") + } + interfaces = map[string]task.Interface{ + surveyCmd.InterfaceType: interfaces[surveyCmd.InterfaceType], + } + } + + if cmd.Flags().Changed("stage") { + if surveyCmd.Task < 1 || surveyCmd.Task > len(surveyTasks) { + return fmt.Errorf("invalid stage") + } + if err := task.RunTask(cmd.Context(), surveyTasks[surveyCmd.Task-1], + interfaces[surveyCmd.InterfaceType], tasks.InterfaceToType(interfaces[surveyCmd.InterfaceType])); err != nil { + return err + } + return nil + } + + if err := newIntroModel().Run(); err != nil { + return returnIfUserQuit(err, "failed to run intro") + } + + if err := taskLoop(cmd.Context(), surveyTasks, interfaces); err != nil { + return returnIfUserQuit(err, "task loop failed") + } + return nil } func taskLoop(ctx context.Context, surveyTasks []task.Tasker, interfaces map[string]task.Interface) error { - var ifaceNames []string + var iNames []string for name := range interfaces { - ifaceNames = append(ifaceNames, name) + iNames = append(iNames, name) } - rand.Shuffle(len(ifaceNames), func(i, j int) { - ifaceNames[i], ifaceNames[j] = ifaceNames[j], ifaceNames[i] + rand.Shuffle(len(iNames), func(i, j int) { + iNames[i], iNames[j] = iNames[j], iNames[i] }) for i, t := range surveyTasks { - idx := i % len(ifaceNames) - selected := interfaces[ifaceNames[idx]] + idx := i % len(iNames) + selected := interfaces[iNames[idx]] - if err := runTask(ctx, t, selected); err != nil { + if err := task.RunTask(ctx, t, selected, tasks.InterfaceToType(selected)); err != nil { return err } } diff --git a/cmd/survey/tasks/base.go b/cmd/survey/tasks/base.go index ccc8bbe..3432e5d 100644 --- a/cmd/survey/tasks/base.go +++ b/cmd/survey/tasks/base.go @@ -2,7 +2,7 @@ package tasks import ( "github.com/LazyBachelor/LazyPM/internal/service" - "github.com/LazyBachelor/LazyPM/pkg/cli/repl" + "github.com/LazyBachelor/LazyPM/pkg/repl" "github.com/LazyBachelor/LazyPM/pkg/task" taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui" "github.com/LazyBachelor/LazyPM/pkg/tui" diff --git a/pkg/cli/commands/close.go b/internal/commands/issues/close.go similarity index 87% rename from pkg/cli/commands/close.go rename to internal/commands/issues/close.go index 2bafcbb..3c3dc80 100644 --- a/pkg/cli/commands/close.go +++ b/internal/commands/issues/close.go @@ -1,4 +1,4 @@ -package commands +package issuesCmd import ( "fmt" @@ -7,9 +7,9 @@ import ( "github.com/spf13/cobra" ) -// closeCmd represents the close command, +// CloseCmd represents the close command, // which allows users to close an existing issue by its ID. -var closeCmd = &cobra.Command{ +var CloseCmd = &cobra.Command{ Use: "close [id]", Short: "Close an existing issue", Long: `Close an existing issue by its ID.`, @@ -58,8 +58,3 @@ func runCloseCmd(cmd *cobra.Command, args []string) error { return nil } - -// init function to set up the close command and its flags. -func init() { - rootCmd.AddCommand(closeCmd) -} diff --git a/pkg/cli/commands/completion.go b/internal/commands/issues/completion.go similarity index 99% rename from pkg/cli/commands/completion.go rename to internal/commands/issues/completion.go index e164585..7330a28 100644 --- a/pkg/cli/commands/completion.go +++ b/internal/commands/issues/completion.go @@ -1,4 +1,4 @@ -package commands +package issuesCmd import ( "context" diff --git a/pkg/cli/commands/create.go b/internal/commands/issues/create.go similarity index 82% rename from pkg/cli/commands/create.go rename to internal/commands/issues/create.go index a270f03..17a9864 100644 --- a/pkg/cli/commands/create.go +++ b/internal/commands/issues/create.go @@ -1,4 +1,4 @@ -package commands +package issuesCmd import ( "fmt" @@ -18,8 +18,8 @@ const ( pm create Fix bug --desc "Bug description" --status in_progress --type bug --priority 5` ) -// createCmd represents the create command, which allows users to create a new issue with specified details. -var createCmd = &cobra.Command{ +// CreateCmd represents the create command, which allows users to create a new issue with specified details. +var CreateCmd = &cobra.Command{ Use: "create [title]", Short: "Create a new issue", Long: `Create a new issue with the specified details.`, @@ -105,15 +105,13 @@ func runCreateInteractive() error { // init function to set up the create command and its flags. func init() { - createCmd.Flags().BoolVarP(&createFlags.interactive, "interactive", "i", false, "Create issue interactively") - createCmd.Flags().StringVarP(&createFlags.description, "desc", "d", "", "Issue description") - createCmd.Flags().StringVarP(&createFlags.status, "status", "s", "open", "Issue status(open, closed, in_progress)") - createCmd.Flags().StringVarP(&createFlags.issueType, "type", "t", "task", "Issue type(bug, feature, task)") - createCmd.Flags().IntVarP(&createFlags.priority, "priority", "p", 0, "Issue priority(0-4)") + CreateCmd.Flags().BoolVarP(&createFlags.interactive, "interactive", "i", false, "Create issue interactively") + CreateCmd.Flags().StringVarP(&createFlags.description, "desc", "d", "", "Issue description") + CreateCmd.Flags().StringVarP(&createFlags.status, "status", "s", "open", "Issue status(open, closed, in_progress)") + CreateCmd.Flags().StringVarP(&createFlags.issueType, "type", "t", "task", "Issue type(bug, feature, task)") + CreateCmd.Flags().IntVarP(&createFlags.priority, "priority", "p", 0, "Issue priority(0-4)") - createCmd.RegisterFlagCompletionFunc("type", completionFunc(typeOptions)) - createCmd.RegisterFlagCompletionFunc("status", completionFunc(statusOptions)) - createCmd.RegisterFlagCompletionFunc("priority", completionFunc(priorityRange)) - - rootCmd.AddCommand(createCmd) + CreateCmd.RegisterFlagCompletionFunc("type", completionFunc(typeOptions)) + CreateCmd.RegisterFlagCompletionFunc("status", completionFunc(statusOptions)) + CreateCmd.RegisterFlagCompletionFunc("priority", completionFunc(priorityRange)) } diff --git a/pkg/cli/commands/delete.go b/internal/commands/issues/delete.go similarity index 92% rename from pkg/cli/commands/delete.go rename to internal/commands/issues/delete.go index 0b66590..8a4ae2e 100644 --- a/pkg/cli/commands/delete.go +++ b/internal/commands/issues/delete.go @@ -1,4 +1,4 @@ -package commands +package issuesCmd import ( "context" @@ -17,8 +17,8 @@ var ( deleteInteractive bool ) -// deleteCmd represents the delete command. -var deleteCmd = &cobra.Command{ +// DeleteCmd represents the delete command. +var DeleteCmd = &cobra.Command{ Use: "delete [id]", Short: "Delete an existing issue", Long: `Delete an existing issue by its ID.`, @@ -125,8 +125,6 @@ func runDeleteInteractive(ctx context.Context) error { // init function to set up the delete command and its flags. func init() { - deleteCmd.Flags().BoolVarP(&deleteInteractive, "interactive", "i", false, "Delete issues interactively") - deleteCmd.Flags().BoolVarP(&confirmDelete, "yes", "y", true, "Confirm deletion without prompt") - - rootCmd.AddCommand(deleteCmd) + DeleteCmd.Flags().BoolVarP(&deleteInteractive, "interactive", "i", false, "Delete issues interactively") + DeleteCmd.Flags().BoolVarP(&confirmDelete, "yes", "y", true, "Confirm deletion without prompt") } diff --git a/pkg/cli/commands/ls.go b/internal/commands/issues/list.go similarity index 63% rename from pkg/cli/commands/ls.go rename to internal/commands/issues/list.go index 1eb13cc..79f55e0 100644 --- a/pkg/cli/commands/ls.go +++ b/internal/commands/issues/list.go @@ -1,4 +1,4 @@ -package commands +package issuesCmd import ( "strings" @@ -17,8 +17,8 @@ pm list --title "New feature" --desc "feature description" pm list -p 1 -l 10` ) -// getIssuesCmd represents the get issues command. -var getIssuesCmd = &cobra.Command{ +// ListCmd represents the get issues command. +var ListCmd = &cobra.Command{ Use: "list [search query]", Short: "List all issues", Long: `List all issues in the project management system.`, @@ -70,17 +70,15 @@ func runGetIssuesCmd(cmd *cobra.Command, args []string) error { // init function to set up the get issues command and its flags. func init() { - getIssuesCmd.Flags().StringVar(&listFlags.title, "title", "", "Filter issues by title") - getIssuesCmd.Flags().StringVarP(&listFlags.description, "desc", "d", "", "Filter issues by description") - getIssuesCmd.Flags().StringVarP(&listFlags.status, "status", "s", "", "Filter issues by status (open, closed, in_progress)") - getIssuesCmd.Flags().StringVarP(&listFlags.issueType, "type", "t", "", "Filter issues by type (bug, feature, task)") - getIssuesCmd.Flags().IntVarP(&listFlags.priority, "priority", "p", 0, "Filter issues by priority (0-4)") + ListCmd.Flags().StringVar(&listFlags.title, "title", "", "Filter issues by title") + ListCmd.Flags().StringVarP(&listFlags.description, "desc", "d", "", "Filter issues by description") + ListCmd.Flags().StringVarP(&listFlags.status, "status", "s", "", "Filter issues by status (open, closed, in_progress)") + ListCmd.Flags().StringVarP(&listFlags.issueType, "type", "t", "", "Filter issues by type (bug, feature, task)") + ListCmd.Flags().IntVarP(&listFlags.priority, "priority", "p", 0, "Filter issues by priority (0-4)") - getIssuesCmd.Flags().IntVarP(&listFlags.limit, "limit", "l", 25, "Limit the number of issues returned") + ListCmd.Flags().IntVarP(&listFlags.limit, "limit", "l", 25, "Limit the number of issues returned") - getIssuesCmd.RegisterFlagCompletionFunc("status", completionFunc(statusOptions)) - getIssuesCmd.RegisterFlagCompletionFunc("type", completionFunc(typeOptions)) - getIssuesCmd.RegisterFlagCompletionFunc("priority", completionFunc(priorityRange)) - - rootCmd.AddCommand(getIssuesCmd) + ListCmd.RegisterFlagCompletionFunc("status", completionFunc(statusOptions)) + ListCmd.RegisterFlagCompletionFunc("type", completionFunc(typeOptions)) + ListCmd.RegisterFlagCompletionFunc("priority", completionFunc(priorityRange)) } diff --git a/pkg/cli/commands/read.go b/internal/commands/issues/read.go similarity index 88% rename from pkg/cli/commands/read.go rename to internal/commands/issues/read.go index 40c8c8c..c070fcf 100644 --- a/pkg/cli/commands/read.go +++ b/internal/commands/issues/read.go @@ -1,12 +1,12 @@ -package commands +package issuesCmd import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/spf13/cobra" ) -// getIssueCmd represents the get issue command. -var getIssueCmd = &cobra.Command{ +// GetCmd represents the get issue command. +var GetCmd = &cobra.Command{ Use: "describe [issue ID]", Short: "Get issue details", Long: `Get issue details by ID`, @@ -44,5 +44,4 @@ func runGetCmd(cmd *cobra.Command, args []string) error { // init function to set up the get issue command. func init() { - rootCmd.AddCommand(getIssueCmd) } diff --git a/pkg/cli/commands/root.go b/internal/commands/issues/root.go similarity index 69% rename from pkg/cli/commands/root.go rename to internal/commands/issues/root.go index 45b1c45..16c24f2 100644 --- a/pkg/cli/commands/root.go +++ b/internal/commands/issues/root.go @@ -1,4 +1,4 @@ -package commands +package issuesCmd import ( "bytes" @@ -29,8 +29,8 @@ type Flags struct { priority int } -// rootCmd is the base command for the CLI application. -var rootCmd = &cobra.Command{ +// RootCmd is the base command for the CLI application. +var RootCmd = &cobra.Command{ Short: "Project Management CLI", Long: `Project Management CLI for managing issues and tasks.`, PersistentPreRun: func(cmd *cobra.Command, args []string) { @@ -45,7 +45,7 @@ var rootCmd = &cobra.Command{ // Must be called before executing any commands to ensure services are available. func SetApp(application *service.App) { app = application - rootCmd.Use = app.Config.RootCmd + RootCmd.Use = app.Config.RootCmd } // AppFromContext retrieves the App from the command context @@ -57,37 +57,37 @@ func AppFromContext(ctx context.Context) *service.App { return app } -// Execute executes the root command using the fang library. -func Execute() error { - return fang.Execute(context.Background(), rootCmd, - fang.WithColorSchemeFunc(fang.AnsiColorScheme)) -} - // ExecuteArgs executes the command with the given arguments using the fang library. func ExecuteArgs(args []string) error { - rootCmd.SetArgs(args) - return fang.Execute(context.Background(), rootCmd, + RootCmd.SetArgs(args) + return fang.Execute(context.Background(), RootCmd, fang.WithColorSchemeFunc(fang.AnsiColorScheme)) } // ExecuteArgsString executes the command with the given arguments and returns the output as a string. // This is useful for testing command outputs and used in the REPL func ExecuteArgsString(args []string) (string, error) { + return ExecuteArgsStringWithContext(context.Background(), args) +} + +// ExecuteArgsStringWithContext executes the command with context and returns the output as a string. +func ExecuteArgsStringWithContext(ctx context.Context, args []string) (string, error) { buf := new(bytes.Buffer) - rootCmd.SetOut(buf) - rootCmd.SetErr(buf) - rootCmd.SetArgs(args) + RootCmd.SetOut(buf) + RootCmd.SetErr(buf) + RootCmd.SetArgs(args) + RootCmd.SetContext(ctx) - err := rootCmd.Execute() + err := RootCmd.Execute() return buf.String(), err } // init function to set up the command hierarchy and options. func init() { - rootCmd.CompletionOptions.DisableDefaultCmd = false - rootCmd.AddGroup(&cobra.Group{ID: "help", Title: "Helping Commands"}) - rootCmd.SetCompletionCommandGroupID("help") - rootCmd.SetHelpCommandGroupID("help") + RootCmd.CompletionOptions.DisableDefaultCmd = false + RootCmd.AddGroup(&cobra.Group{ID: "help", Title: "Helping Commands"}) + RootCmd.SetCompletionCommandGroupID("help") + RootCmd.SetHelpCommandGroupID("help") } diff --git a/pkg/cli/commands/update.go b/internal/commands/issues/update.go similarity index 80% rename from pkg/cli/commands/update.go rename to internal/commands/issues/update.go index f26b23e..92b87a8 100644 --- a/pkg/cli/commands/update.go +++ b/internal/commands/issues/update.go @@ -1,4 +1,4 @@ -package commands +package issuesCmd import ( "fmt" @@ -9,7 +9,7 @@ import ( var updateFlags Flags -var updateCmd = &cobra.Command{ +var UpdateCmd = &cobra.Command{ Use: "update [issue ID]", Short: "Update an existing issue", Long: `Update an existing issue by its ID with the specified details.`, @@ -55,17 +55,15 @@ func runUpdateCmd(cmd *cobra.Command, args []string) error { } func init() { - updateCmd.Flags().StringVar(&updateFlags.title, "title", "", "New issue title") - updateCmd.Flags().StringVarP(&updateFlags.description, "desc", "d", "", "New issue description") - updateCmd.Flags().StringVarP(&updateFlags.status, "status", "s", "", "New issue status(open, closed, in_progress)") - updateCmd.Flags().StringVarP(&updateFlags.issueType, "type", "t", "", "New issue type(bug, feature, task)") - updateCmd.Flags().IntVarP(&updateFlags.priority, "priority", "p", 0, "New issue priority(0-5)") + UpdateCmd.Flags().StringVar(&updateFlags.title, "title", "", "New issue title") + UpdateCmd.Flags().StringVarP(&updateFlags.description, "desc", "d", "", "New issue description") + UpdateCmd.Flags().StringVarP(&updateFlags.status, "status", "s", "", "New issue status(open, closed, in_progress)") + UpdateCmd.Flags().StringVarP(&updateFlags.issueType, "type", "t", "", "New issue type(bug, feature, task)") + UpdateCmd.Flags().IntVarP(&updateFlags.priority, "priority", "p", 0, "New issue priority(0-5)") - updateCmd.RegisterFlagCompletionFunc("type", completionFunc(typeOptions)) - updateCmd.RegisterFlagCompletionFunc("status", completionFunc(statusOptions)) - updateCmd.RegisterFlagCompletionFunc("priority", completionFunc(priorityRange)) - - rootCmd.AddCommand(updateCmd) + UpdateCmd.RegisterFlagCompletionFunc("type", completionFunc(typeOptions)) + UpdateCmd.RegisterFlagCompletionFunc("status", completionFunc(statusOptions)) + UpdateCmd.RegisterFlagCompletionFunc("priority", completionFunc(priorityRange)) } func getUpdateValues(cmd *cobra.Command) (map[string]interface{}, error) { diff --git a/internal/commands/survey/list.go b/internal/commands/survey/list.go new file mode 100644 index 0000000..6b4d6fa --- /dev/null +++ b/internal/commands/survey/list.go @@ -0,0 +1,17 @@ +package surveyCmd + +import ( + "github.com/LazyBachelor/LazyPM/pkg/task" + "github.com/spf13/cobra" +) + +var ListCmd = &cobra.Command{ + Use: "list", + Short: "List available tasks", + RunE: func(cmd *cobra.Command, args []string) error { + for i, name := range task.List() { + cmd.Printf("%d. %s\n", i+1, name) + } + return nil + }, +} diff --git a/internal/commands/survey/root.go b/internal/commands/survey/root.go new file mode 100644 index 0000000..b5e00a9 --- /dev/null +++ b/internal/commands/survey/root.go @@ -0,0 +1,29 @@ +package surveyCmd + +import ( + "github.com/spf13/cobra" +) + +var ( + InterfaceType string + Task int +) + +// RootCmd is the base command for the survey CLI. +var RootCmd = &cobra.Command{ + Use: "survey", + Long: `Project Management Interface Survey + +Thank you for participating in our survey! + +We are gathering data on how users interact with different task management interfaces to better understand their preferences and compare their usability. +This survey will present you with a series of tasks to complete using various interfaces, including command-line, web-based, and terminal user interfaces. + +Please answer the questions honestly and to the best of your ability. +Your responses will be kept confidential and used solely for research purposes.`} + +func init() { + RootCmd.CompletionOptions.DisableDefaultCmd = true + StartCmd.Flags().StringVarP(&InterfaceType, "interface", "i", "tui", "Specify interface.") + StartCmd.Flags().IntVarP(&Task, "task", "t", 1, "Run task directly") +} diff --git a/internal/commands/survey/start.go b/internal/commands/survey/start.go new file mode 100644 index 0000000..c987941 --- /dev/null +++ b/internal/commands/survey/start.go @@ -0,0 +1,9 @@ +package surveyCmd + +import "github.com/spf13/cobra" + +// StartCmd is the start command - RunE is set in cmd/survey/ +var StartCmd = &cobra.Command{ + Use: "start", + Short: "Start the user survey", +} diff --git a/internal/commands/survey/status.go b/internal/commands/survey/status.go new file mode 100644 index 0000000..8983bff --- /dev/null +++ b/internal/commands/survey/status.go @@ -0,0 +1,30 @@ +package surveyCmd + +import ( + "github.com/LazyBachelor/LazyPM/internal/commands/issues" + "github.com/spf13/cobra" +) + +// StatusCmd displays the current task validation status +var StatusCmd = &cobra.Command{ + Use: "status", + Short: "Check task validation status", + Long: "Displays the current task validation status and feedback.", + RunE: runStatusCmd, +} + +func runStatusCmd(cmd *cobra.Command, args []string) error { + app := issuesCmd.AppFromContext(cmd.Context()) + if app == nil || app.CurrentFeedback == nil { + cmd.Println("No validation status available.") + return nil + } + + if app.CurrentFeedback.Message == "" { + cmd.Println("No validation status available yet.") + return nil + } + + cmd.Print(app.CurrentFeedback.Message) + return nil +} diff --git a/internal/commands/survey/submit.go b/internal/commands/survey/submit.go new file mode 100644 index 0000000..9766e6c --- /dev/null +++ b/internal/commands/survey/submit.go @@ -0,0 +1,12 @@ +package surveyCmd + +import "github.com/spf13/cobra" + +var SubmitCmd = &cobra.Command{ + Use: "submit", + Short: "Submit your survey responses", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.Println("Submitting responses and metrics...") + return nil + }, +} diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go index 33095ba..6275d27 100644 --- a/internal/service/interfaces.go +++ b/internal/service/interfaces.go @@ -8,11 +8,18 @@ import ( "github.com/steveyegge/beads" ) +// ValidationFeedback holds task validation status +type ValidationFeedback struct { + Success bool + Message string +} + type App struct { - Config Config - Issues IssueService - Stats StatsService - Logger *slog.Logger + Config Config + Issues IssueService + Stats StatsService + Logger *slog.Logger + CurrentFeedback *ValidationFeedback } type IssueService interface { diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index 420e37d..1b9d01f 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -4,17 +4,23 @@ package cli import ( "context" + "github.com/LazyBachelor/LazyPM/internal/commands/issues" "github.com/LazyBachelor/LazyPM/internal/service" - "github.com/LazyBachelor/LazyPM/pkg/cli/commands" + "github.com/charmbracelet/fang" + "github.com/spf13/cobra" ) // Config is an alias for service.Config, used to configure the CLI. type Config = service.Config -type CLI struct{} +type CLI struct { + RootCmd *cobra.Command +} -func NewCli() *CLI { - return &CLI{} +func NewCli(rootCmd *cobra.Command) *CLI { + return &CLI{ + RootCmd: rootCmd, + } } // Run initializes the services and executes the CLI commands. @@ -26,9 +32,10 @@ func (c *CLI) Run(ctx context.Context, config Config) error { defer cleanup() - commands.SetApp(app) + issuesCmd.SetApp(app) - if err := commands.Execute(); err != nil { + if err := fang.Execute(ctx, c.RootCmd, + fang.WithColorSchemeFunc(fang.AnsiColorScheme)); err != nil { return err } @@ -44,9 +51,9 @@ func (c *CLI) RunWithArgs(ctx context.Context, config Config, args []string) err defer cleanup() - commands.SetApp(app) + issuesCmd.SetApp(app) - if err := commands.ExecuteArgs(args); err != nil { + if err := issuesCmd.ExecuteArgs(args); err != nil { return err } diff --git a/pkg/cli/commands/status.go b/pkg/cli/commands/status.go deleted file mode 100644 index ad11ec8..0000000 --- a/pkg/cli/commands/status.go +++ /dev/null @@ -1,46 +0,0 @@ -package commands - -import ( - "github.com/LazyBachelor/LazyPM/pkg/task" - "github.com/spf13/cobra" -) - -// replInstance holds a reference to the REPL for accessing validation feedback -var replInstance interface { - GetCurrentFeedback() task.ValidationFeedback -} - -// SetRepl sets the REPL instance for use by commands -func SetRepl(repl interface { - GetCurrentFeedback() task.ValidationFeedback -}) { - replInstance = repl -} - -// StatusCmd displays the current task validation status -var StatusCmd = &cobra.Command{ - Use: "status", - Short: "Check task validation status", - Long: "Displays the current task validation status and feedback.", - RunE: runStatusCmd, -} - -func runStatusCmd(cmd *cobra.Command, args []string) error { - if replInstance == nil { - cmd.Println("No task validation available") - return nil - } - - feedback := replInstance.GetCurrentFeedback() - if feedback.Message == "" { - cmd.Println("No validation status available yet.") - return nil - } - - cmd.Print(feedback.Message) - - return nil -} -func init() { - rootCmd.AddCommand(StatusCmd) -} diff --git a/pkg/cli/styles/styles.go b/pkg/cli/styles/styles.go deleted file mode 100644 index e4bf744..0000000 --- a/pkg/cli/styles/styles.go +++ /dev/null @@ -1,10 +0,0 @@ -// Package styles defines the styling for the CLI output using the lipgloss library. -package styles - -import "github.com/charmbracelet/lipgloss" - -var ( - TitleStyle = lipgloss.NewStyle().Bold(true).Padding(1) - - CommandStyle = lipgloss.NewStyle().Padding(1) -) diff --git a/pkg/cli/repl/completer.go b/pkg/repl/completer.go similarity index 100% rename from pkg/cli/repl/completer.go rename to pkg/repl/completer.go diff --git a/pkg/cli/repl/executor.go b/pkg/repl/executor.go similarity index 63% rename from pkg/cli/repl/executor.go rename to pkg/repl/executor.go index c0c50a7..037528f 100644 --- a/pkg/cli/repl/executor.go +++ b/pkg/repl/executor.go @@ -4,12 +4,10 @@ import ( "os/exec" "strings" - "github.com/LazyBachelor/LazyPM/pkg/cli/commands" + "github.com/LazyBachelor/LazyPM/internal/commands/issues" ) // execute processes the input command and returns the output -// or an error if it occurs. It handles what type of command is being executed, -// whether it's a PM command or a shell command, and routes it accordingly. func execute(input string) (string, error) { if input == "" { return "", nil @@ -29,8 +27,6 @@ func execute(input string) (string, error) { return executeShellCommand(input) } -// executeShellCommand executes a shell command -// and returns its output or an error if it occurs. func executeShellCommand(input string) (string, error) { parts := strings.Fields(input) if len(parts) == 0 { @@ -42,14 +38,12 @@ func executeShellCommand(input string) (string, error) { return string(output), err } -// executePMCommand executes a PM command using the commands package -// and returns its output or an error if it occurs. func executePMCommand(input string) (string, error) { parts := strings.Fields(input) if len(parts) == 0 { return "", nil } - output, err := commands.ExecuteArgsString(parts) + output, err := issuesCmd.ExecuteArgsString(parts) return output, err } diff --git a/pkg/cli/repl/options.go b/pkg/repl/options.go similarity index 100% rename from pkg/cli/repl/options.go rename to pkg/repl/options.go diff --git a/pkg/cli/repl/repl.go b/pkg/repl/repl.go similarity index 74% rename from pkg/cli/repl/repl.go rename to pkg/repl/repl.go index ff49153..bfbf51f 100644 --- a/pkg/cli/repl/repl.go +++ b/pkg/repl/repl.go @@ -7,11 +7,13 @@ import ( "os" "strings" + "github.com/LazyBachelor/LazyPM/internal/commands/issues" + surveyCmd "github.com/LazyBachelor/LazyPM/internal/commands/survey" "github.com/LazyBachelor/LazyPM/internal/service" + "github.com/LazyBachelor/LazyPM/internal/style" "github.com/LazyBachelor/LazyPM/pkg/cli" - "github.com/LazyBachelor/LazyPM/pkg/cli/commands" - "github.com/LazyBachelor/LazyPM/pkg/cli/styles" "github.com/LazyBachelor/LazyPM/pkg/task" + "github.com/LazyBachelor/LazyPM/pkg/tui/styles" "github.com/c-bata/go-prompt" "golang.org/x/term" ) @@ -27,6 +29,7 @@ You can also run shell commands directly. Type 'exit' or 'quit' to leave.` type REPL struct { feedbackChan chan task.ValidationFeedback quitChan chan bool + app *service.App currentFeedback task.ValidationFeedback exitRequested bool @@ -55,12 +58,12 @@ func (r *REPL) Run(ctx context.Context, config cli.Config) error { defer cleanup() // Make sure to set app, to ensure they are available. - commands.SetApp(app) + issuesCmd.SetApp(app) - // Set the REPL instance so status command can access it - commands.SetRepl(r) + // Store app reference for updating feedback + r.app = app - fmt.Println(styles.TitleStyle.Render(ReplTitle)) // Print REPL title. + fmt.Println(style.TitleStyle.Render(ReplTitle)) // Print REPL title. // Start goroutine to watch for validation feedback and quit signals if r.feedbackChan != nil && r.quitChan != nil { @@ -102,8 +105,8 @@ func (r *REPL) Run(ctx context.Context, config cli.Config) error { // Add the input to the history for future navigation. history = append(history, input) - output, _ := execute(input) // Ignore errors for now, gives better ux - fmt.Println(styles.CommandStyle.Render(output)) // Print the output of the command in a styled format. + output, _ := execute(input) // Ignore errors for now, gives better ux + fmt.Println(style.TextStyle.Render(output)) // Print the output of the command in a styled format. } return nil @@ -114,6 +117,13 @@ func (r *REPL) watchValidation() { select { case feedback := <-r.feedbackChan: r.currentFeedback = feedback + // Update app's CurrentFeedback so status command can access it + if r.app != nil { + r.app.CurrentFeedback = &service.ValidationFeedback{ + Success: feedback.Success, + Message: feedback.Message, + } + } if feedback.Success { fmt.Printf("\n%s\n", styles.TitleStyle.Render("Task completed successfully!")) fmt.Println("Press Enter to exit...") @@ -127,13 +137,18 @@ func (r *REPL) watchValidation() { } } -// GetCurrentFeedback returns the current validation feedback for the status command -func (r *REPL) GetCurrentFeedback() task.ValidationFeedback { - return r.currentFeedback -} - // SetChannels sets the channels for receiving validation feedback and quit signals from the task interface func (r *REPL) SetChannels(feedbackChan chan task.ValidationFeedback, quitChan chan bool) { r.feedbackChan = feedbackChan r.quitChan = quitChan } + +func init() { + issuesCmd.RootCmd.AddCommand(issuesCmd.GetCmd) + issuesCmd.RootCmd.AddCommand(issuesCmd.ListCmd) + issuesCmd.RootCmd.AddCommand(issuesCmd.CloseCmd) + issuesCmd.RootCmd.AddCommand(issuesCmd.CreateCmd) + issuesCmd.RootCmd.AddCommand(issuesCmd.DeleteCmd) + issuesCmd.RootCmd.AddCommand(issuesCmd.UpdateCmd) + issuesCmd.RootCmd.AddCommand(surveyCmd.StatusCmd) +} diff --git a/pkg/cli/repl/suggestions.go b/pkg/repl/suggestions.go similarity index 97% rename from pkg/cli/repl/suggestions.go rename to pkg/repl/suggestions.go index e99c681..522afa1 100644 --- a/pkg/cli/repl/suggestions.go +++ b/pkg/repl/suggestions.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/LazyBachelor/LazyPM/pkg/cli/commands" + "github.com/LazyBachelor/LazyPM/internal/commands/issues" "github.com/c-bata/go-prompt" "github.com/muesli/reflow/truncate" ) @@ -21,6 +21,7 @@ var rootSuggestions = []prompt.Suggest{ // commandSuggestions is a list of prompt suggestions for PM commands. var baseSuggestions = []prompt.Suggest{ {Text: "help", Description: "Show help information"}, + {Text: "status", Description: "Show task status"}, {Text: "delete", Description: "Delete an issue by ID"}, {Text: "close", Description: "Close an issue by ID"}, {Text: "create", Description: "Create a new issue with title"}, @@ -148,7 +149,7 @@ func issueIDSuggestions(partial string, hasCommand bool) []prompt.Suggest { return nil } - issues, _ := commands.GetIssueCompletions(context.Background(), partial) + issues, _ := issuesCmd.GetIssueCompletions(context.Background(), partial) var suggestions []prompt.Suggest for _, issue := range issues { diff --git a/pkg/task/runner.go b/pkg/task/runner.go index d060cdd..3af739f 100644 --- a/pkg/task/runner.go +++ b/pkg/task/runner.go @@ -15,7 +15,7 @@ import ( // 3. Run the interface // 4. Start validation loop in background // 5. Show questionnaire when done -func RunTask(ctx context.Context, t Tasker, i Interface, ifaceType InterfaceType) error { +func RunTask(ctx context.Context, t Tasker, i Interface, iType InterfaceType) error { doneChan := make(chan bool, 1) quitChan := make(chan bool, 1) feedbackChan := make(chan ValidationFeedback, 10) @@ -65,7 +65,7 @@ func RunTask(ctx context.Context, t Tasker, i Interface, ifaceType InterfaceType } // Show questionnaire - questions := t.Questions(ifaceType) + questions := t.Questions(iType) questionare := taskui.NewQuestionnaireModel(questions) model, err = tea.NewProgram(questionare, tea.WithAltScreen()).Run() if err != nil { From 78769396e7c000919d1775c337115fa511f6bd53 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Sun, 22 Feb 2026 19:47:17 +0100 Subject: [PATCH 12/13] forgot to commit this --- cmd/survey/init.go | 41 ++++++++++++++++------------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/cmd/survey/init.go b/cmd/survey/init.go index 6587a51..fc17c95 100644 --- a/cmd/survey/init.go +++ b/cmd/survey/init.go @@ -3,39 +3,16 @@ package main import ( "context" + "github.com/LazyBachelor/LazyPM/cmd/survey/tasks" "github.com/LazyBachelor/LazyPM/internal/service" - "github.com/LazyBachelor/LazyPM/pkg/cli/repl" + "github.com/LazyBachelor/LazyPM/pkg/repl" "github.com/LazyBachelor/LazyPM/pkg/task" "github.com/LazyBachelor/LazyPM/pkg/tui" "github.com/LazyBachelor/LazyPM/pkg/web" - "github.com/LazyBachelor/LazyPM/cmd/survey/tasks" _ "github.com/LazyBachelor/LazyPM/cmd/survey/tasks" ) -func init() { - task.Register("create_issue", func(app *service.App) task.Tasker { - return tasks.NewCreateIssueTask(app) - }) - task.Register("coding_task", func(app *service.App) task.Tasker { - return tasks.NewCodingTask(app) - }) -} - -func initTasks(app *service.App) []task.Tasker { - var taskers []task.Tasker - - for _, name := range task.List() { - t, err := task.Get(name, app) - if err != nil { - continue - } - taskers = append(taskers, t) - } - - return taskers -} - func initializeServices(ctx context.Context) (*service.App, func(), error) { return service.NewServices(ctx, tasks.BaseConfig()) } @@ -47,3 +24,17 @@ func initInterfaces() map[string]task.Interface { "web": web.NewWeb(), } } + +func initTasks(app *service.App) []task.Tasker { + var taskList []task.Tasker + + for _, name := range task.List() { + t, err := task.Get(name, app) + if err != nil { + continue + } + taskList = append(taskList, t) + } + + return taskList +} From 235b07f7cec22487fb3e75e34d301103443ee466 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Sun, 22 Feb 2026 22:58:04 +0100 Subject: [PATCH 13/13] use registry for interfaces also add list interfaces command --- cmd/survey/init.go | 20 +++++++------- cmd/survey/main.go | 17 +++++++++--- cmd/survey/runner.go | 2 +- internal/commands/survey/list.go | 21 ++++++++++++--- pkg/task/register.go | 45 +++++++++++++++++++++++++------- 5 files changed, 76 insertions(+), 29 deletions(-) diff --git a/cmd/survey/init.go b/cmd/survey/init.go index fc17c95..6c67f65 100644 --- a/cmd/survey/init.go +++ b/cmd/survey/init.go @@ -5,10 +5,7 @@ import ( "github.com/LazyBachelor/LazyPM/cmd/survey/tasks" "github.com/LazyBachelor/LazyPM/internal/service" - "github.com/LazyBachelor/LazyPM/pkg/repl" "github.com/LazyBachelor/LazyPM/pkg/task" - "github.com/LazyBachelor/LazyPM/pkg/tui" - "github.com/LazyBachelor/LazyPM/pkg/web" _ "github.com/LazyBachelor/LazyPM/cmd/survey/tasks" ) @@ -18,18 +15,21 @@ func initializeServices(ctx context.Context) (*service.App, func(), error) { } func initInterfaces() map[string]task.Interface { - return map[string]task.Interface{ - "repl": repl.NewRepl(), - "tui": tui.NewTui(), - "web": web.NewWeb(), + interfaces := make(map[string]task.Interface) + for _, name := range task.ListInterfaces() { + i, err := task.GetInterface(name) + if err != nil { + continue + } + interfaces[name] = i } + return interfaces } func initTasks(app *service.App) []task.Tasker { var taskList []task.Tasker - - for _, name := range task.List() { - t, err := task.Get(name, app) + for _, name := range task.ListTasks() { + t, err := task.GetTasks(name, app) if err != nil { continue } diff --git a/cmd/survey/main.go b/cmd/survey/main.go index 085b265..850a620 100644 --- a/cmd/survey/main.go +++ b/cmd/survey/main.go @@ -4,9 +4,12 @@ import ( "context" "github.com/LazyBachelor/LazyPM/cmd/survey/tasks" - "github.com/LazyBachelor/LazyPM/internal/commands/survey" + surveyCmd "github.com/LazyBachelor/LazyPM/internal/commands/survey" "github.com/LazyBachelor/LazyPM/internal/service" + "github.com/LazyBachelor/LazyPM/pkg/repl" "github.com/LazyBachelor/LazyPM/pkg/task" + "github.com/LazyBachelor/LazyPM/pkg/tui" + "github.com/LazyBachelor/LazyPM/pkg/web" "github.com/charmbracelet/fang" ) @@ -20,16 +23,22 @@ func main() { } func init() { + task.RegisterInterface("tui", tui.NewTui()) + task.RegisterInterface("web", web.NewWeb()) + task.RegisterInterface("repl", repl.NewRepl()) + surveyCmd.StartCmd.RunE = runStartCmd surveyCmd.RootCmd.AddCommand(surveyCmd.StartCmd) surveyCmd.RootCmd.AddCommand(surveyCmd.SubmitCmd) surveyCmd.RootCmd.AddCommand(surveyCmd.StatusCmd) - surveyCmd.RootCmd.AddCommand(surveyCmd.ListCmd) + surveyCmd.RootCmd.AddCommand(surveyCmd.ListTasksCmd) + surveyCmd.RootCmd.AddCommand(surveyCmd.ListInterfacesCmd) - task.Register("create_issue", func(app *service.App) task.Tasker { + task.RegisterTask("create_issue", func(app *service.App) task.Tasker { return tasks.NewCreateIssueTask(app) }) - task.Register("coding_task", func(app *service.App) task.Tasker { + task.RegisterTask("coding_task", func(app *service.App) task.Tasker { return tasks.NewCodingTask(app) }) + } diff --git a/cmd/survey/runner.go b/cmd/survey/runner.go index 6ff6ca0..3b8fd1b 100644 --- a/cmd/survey/runner.go +++ b/cmd/survey/runner.go @@ -25,7 +25,7 @@ func runStartCmd(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("interface") { if _, ok := interfaces[surveyCmd.InterfaceType]; !ok { - return fmt.Errorf("invalid interface, valid are (tui, repl, web)") + return fmt.Errorf("invalid interface, valid are %v", task.ListInterfaces()) } interfaces = map[string]task.Interface{ surveyCmd.InterfaceType: interfaces[surveyCmd.InterfaceType], diff --git a/internal/commands/survey/list.go b/internal/commands/survey/list.go index 6b4d6fa..80629a2 100644 --- a/internal/commands/survey/list.go +++ b/internal/commands/survey/list.go @@ -5,11 +5,24 @@ import ( "github.com/spf13/cobra" ) -var ListCmd = &cobra.Command{ - Use: "list", - Short: "List available tasks", +var ListTasksCmd = &cobra.Command{ + Use: "list-tasks", + Aliases: []string{"ls-t"}, + Short: "List available tasks", RunE: func(cmd *cobra.Command, args []string) error { - for i, name := range task.List() { + for i, name := range task.ListTasks() { + cmd.Printf("%d. %s\n", i+1, name) + } + return nil + }, +} + +var ListInterfacesCmd = &cobra.Command{ + Use: "list-interfaces", + Aliases: []string{"ls-i"}, + Short: "List available interfaces", + RunE: func(cmd *cobra.Command, args []string) error { + for i, name := range task.ListInterfaces() { cmd.Printf("%d. %s\n", i+1, name) } return nil diff --git a/pkg/task/register.go b/pkg/task/register.go index 4f57ae4..c98d75e 100644 --- a/pkg/task/register.go +++ b/pkg/task/register.go @@ -6,26 +6,51 @@ import ( "github.com/LazyBachelor/LazyPM/internal/service" ) -var registry = make(map[string]func(*service.App) Tasker) +var interfaceRegistry = make(map[string]Interface) -func Register(name string, constructor func(*service.App) Tasker) { - if _, exists := registry[name]; exists { - panic(fmt.Sprintf("task %q already registered", name)) +func RegisterInterface(name string, iface Interface) { + if _, exists := interfaceRegistry[name]; exists { + panic(fmt.Sprintf("interface %q already registered", name)) } - registry[name] = constructor + interfaceRegistry[name] = iface } -func Get(name string, app *service.App) (Tasker, error) { - constructor, ok := registry[name] +func GetInterface(name string) (Interface, error) { + iface, ok := interfaceRegistry[name] + if !ok { + return nil, fmt.Errorf("interface %q not found", name) + } + return iface, nil +} + +func ListInterfaces() []string { + names := make([]string, 0, len(interfaceRegistry)) + for name := range interfaceRegistry { + names = append(names, name) + } + return names +} + +var taskRegistry = make(map[string]func(*service.App) Tasker) + +func RegisterTask(name string, constructor func(*service.App) Tasker) { + if _, exists := taskRegistry[name]; exists { + panic(fmt.Sprintf("task %q already registered", name)) + } + taskRegistry[name] = constructor +} + +func GetTasks(name string, app *service.App) (Tasker, error) { + constructor, ok := taskRegistry[name] if !ok { return nil, fmt.Errorf("task %q not found", name) } return constructor(app), nil } -func List() []string { - names := make([]string, 0, len(registry)) - for name := range registry { +func ListTasks() []string { + names := make([]string, 0, len(taskRegistry)) + for name := range taskRegistry { names = append(names, name) } return names