diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b152dbb..6d37a9f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -43,3 +43,12 @@ jobs:
with:
name: coverage
path: coverage.out
+ - name: Upload coverage to Codecov
+ if: success() || failure()
+ uses: codecov/codecov-action@v4
+ with:
+ files: coverage.out
+ flags: unittests
+ fail_ci_if_error: false
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 13fe903..06d1795 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,6 +47,10 @@ config.local.yaml
config.local.json
.ticketr.state
+# Generated/throwaway artifacts
+repomix-output.xml
+phase-*.md
+
# Project documentation and templates (development only)
PHASE-*.md
STORY-MARKDOWN-SPEC.md
diff --git a/README.md b/README.md
index bc63fc1..e16d3dd 100644
--- a/README.md
+++ b/README.md
@@ -5,9 +5,13 @@ Bridge Markdown and Jira. Create, update, and sync tickets from simple `.md` fil
For developers who prefer editors and pull requests over tab‑heavy UIs: keep your backlog close to your code, reviewable, and scriptable. Tickets as code — not clicks. ✨
[](https://github.com/karolswdev/ticketr/actions/workflows/ci.yml)
-[](https://go.dev)
+[](https://go.dev)
[](https://pkg.go.dev/github.com/karolswdev/ticketr)
[](https://goreportcard.com/report/github.com/karolswdev/ticketr)
+[](https://app.codecov.io/gh/karolswdev/ticketr)
+[](https://github.com/karolswdev/ticketr/releases)
+[](https://github.com/karolswdev/ticketr/releases)
+[](CONTRIBUTING.md)
[](LICENSE)
## Why Ticketr
diff --git a/phase-cleanup.md b/phase-cleanup.md
deleted file mode 100644
index 1e3448d..0000000
--- a/phase-cleanup.md
+++ /dev/null
@@ -1,238 +0,0 @@
-### **PRIME DIRECTIVE FOR THE EXECUTING AI AGENT**
-
-You are an expert, test-driven software development agent executing a development phase. You **MUST** adhere to the following methodology without deviation:
-
-1. **Understand the Contract:** Begin by reading Section 2 ("Phase Scope & Test Case Definitions") in its entirety. This is your reference library for **what** to test and **how** to prove success.
-2. **Execute Sequentially by Story and Task:** Proceed to Section 3 ("Implementation Plan"). Address each **Story** in order. Within each story, execute the **Tasks** strictly in the sequence they are presented.
-3. **Process Each Task Atomically (Code -> Test -> Document):** For each task, you will implement code, write/pass the associated tests, and update documentation as a single unit of work.
-4. **Escalate Testing (Story & Phase Regression):**
- a. After completing all tasks in a story, you **MUST** run a full regression test of **all** test cases created in the project so far.
- b. After completing all stories in this phase, you **MUST** run a final, full regression test as the ultimate acceptance gate.
-5. **Commit Work:** You **MUST** create a Git commit at the completion of each story. This is a non-negotiable step.
-6. **Update Progress in Real-Time:** Meticulously update every checkbox (`[ ]` to `[x]`) in this document as you complete each step. Your progress tracking must be flawless.
-
-## [x] PHASE-4: The Elite Engine
-
----
-
-### **1. Phase Context (What & Why)**
-
-| ID | Title |
-| :--- | :--- |
-| PHASE-4 | The Elite Engine |
-
-> **As a** Lead Systems Engineer and Open Source Maintainer, **I want** to elevate the entire Ticketr project to an elite standard of quality, implement intelligent and automated workflows, and create world-class documentation, **so that** Ticketr becomes an industry-leading, trusted, and extensible platform for the "Tickets-as-Code" paradigm.
-
----
-
-### **2. Phase Scope & Test Case Definitions (The Contract)**
-
-This section is a reference library defining the acceptance criteria for this phase.
-
-* **Requirement:** **`N/A`** - **World-Class Code Quality & Documentation**
- * **Test Case ID:** `TC-400.1`
- * **Test Method Signature:** `N/A (Code Review & Static Analysis)`
- * **Test Logic:** Reviewers will manually and automatically inspect the codebase for adherence to Go best practices. Key criteria include: every exported function/type has a clear GoDoc comment; complex logic is explained with inline comments; functions are short and single-purpose; error handling is consistent and robust.
- * **Required Proof of Passing:** A code diff showing the addition of comprehensive GoDoc and inline comments to a critical file like `internal/parser/parser.go`.
-
-* **Requirement:** **`USER-301`** - **Interactive Conflict Resolution** (New)
- * **Test Case ID:** `TC-401.1`
- * **Test Method Signature:** `func TestPullService_ResolvesConflictWithLocalWinsStrategy(t *testing.T)`
- * **Test Logic:** (Arrange) Create a conflict scenario. (Act) Run the `pull` service with `--strategy=local-wins`. (Assert) The service completes without error, the final Markdown file contains the local version, and the state file is correctly updated.
- * **Required Proof of Passing:** Console output from `go test` showing the test passing.
- * **Test Case ID:** `TC-401.2`
- * **Test Method Signature:** `func TestPullService_ResolvesConflictWithRemoteWinsStrategy(t *testing.T)`
- * **Test Logic:** (Arrange) Create a conflict scenario. (Act) Run `pull` with `--strategy=remote-wins`. (Assert) The service completes, the final Markdown contains the remote version, and the state file is updated.
- * **Required Proof of Passing:** Console output from `go test` showing the test passing.
-
-* **Requirement:** **`PROD-301`** - **Real-time Synchronization via Webhooks** (New)
- * **Test Case ID:** `TC-402.1`
- * **Test Method Signature:** `func TestWebhookServer_UpdatesFileOnJiraEvent(t *testing.T)`
- * **Test Logic:** (Arrange) Start a mock webhook server. (Act) Send a mock Jira webhook payload to the server. (Assert) The server updates the correct Markdown file and the `.ticketr.state` file.
- * **Required Proof of Passing:** A test that verifies the final contents of the modified Markdown and state files.
-
-* **Requirement:** **`USER-302`** - **Frictionless CI/CD Integration** (New)
- * **Test Case ID:** `TC-403.1`
- * **Test Method Signature:** `N/A (Integration Test)`
- * **Test Logic:** (Arrange) Create a test GitHub repo. (Act) Use the new `ticketr-action` in a workflow. (Assert) The GitHub Action runs successfully and produces the expected logs.
- * **Required Proof of passing:** A link to a successful GitHub Actions run log.
-
-* **Requirement:** **`PROD-302`** - **Workflow Intelligence & Analytics** (New)
- * **Test Case ID:** `TC-404.1`
- * **Test Method Signature:** `func TestStatsCommand_CalculatesCorrectMetrics(t *testing.T)`
- * **Test Logic:** (Arrange) Create a Markdown file with a mix of tickets. (Act) Execute the `stats` command. (Assert) The command outputs a report with correct metrics (ticket counts, status breakdown, total story points).
- * **Required Proof of Passing:** Console output from `go test` showing the test passes, asserting against the captured stdout.
-
----
-
-### **3. Implementation Plan (The Execution)**
-
-#### [x] STORY-400: Establishing the World-Class Baseline
-
-1. **Task:** Perform repository hygiene and organization.
- * **Instruction:** `First, create a new directory named '.pm'. Next, move the 'evidence/' directory and the 'HANDOFF-BEFORE-PHASE-3.md' file into the new '.pm/' directory. Finally, add the line '.pm/' to the root '.gitignore' file to ensure these project management artifacts are not tracked in public clones.`
- * **Fulfills:** This task contributes to the overall project quality standard.
- * **Verification:**
- * [x] **Files Moved & Gitignore Updated:** **Evidence:** Provide the output of `git status` after the move and the diff of the `.gitignore` file.
- * **Documentation:**
- * [x] **Documentation Updated:** Checked after the relevant documentation is updated. **Instruction:** `Create a new top-level document named CONTRIBUTING.md. Add a section titled "Repository Structure" that explains the purpose of the '.pm/' directory for internal project tracking.` **Evidence:** Provide the full content of the new `CONTRIBUTING.md` file.
-
-2. **Task:** Conduct a deep code review and add comprehensive GoDoc comments.
- * **Instruction:** `Thoroughly review the following critical packages: 'internal/parser', 'cmd/ticketr/main.go', 'internal/core/services', and 'internal/adapters/jira'. For every exported type, function, and method, add a clear, concise GoDoc comment explaining its purpose, parameters, and return values. For any complex, non-obvious blocks of internal logic, add inline comments explaining the "why". Refactor any unclear variable names or overly long functions for maximum clarity.`
- * **Fulfills:** This task contributes to requirement **World-Class Code Quality**.
- * **Verification via Test Cases:**
- * **Test Case `TC-400.1`:**
- * [x] **Code Annotated:** Checked after comments are added. **Evidence:** Provide a `git diff` of the `internal/parser/parser.go` file, showing the newly added GoDoc and inline comments.
- * **Documentation:**
- * [x] **Documentation Updated:** Checked after the relevant documentation is updated. **Instruction:** `Update the CONTRIBUTING.md file with a new section titled "Code Style & Commenting" that mandates GoDoc for all exported members.` **Evidence:** Provide the text for this new section.
-
-3. **Task:** Create high-level architectural and development documentation.
- * **Instruction:** `Create a new top-level 'docs/' directory. Inside it, create two files: 1) 'ARCHITECTURE.md', which must contain a MermaidJS diagram of the Ports & Adapters architecture and a detailed description of each component's responsibility. 2) 'DEVELOPMENT.md', which must explain the full local development setup, how to run the test suite, and the branching/commit message strategy.`
- * **Fulfills:** This task contributes to **World-Class Documentation**.
- * **Verification:**
- * [x] **Architecture Document Created:** **Evidence:** Provide the full Markdown content of `docs/ARCHITECTURE.md`.
- * [x] **Development Guide Created:** **Evidence:** Provide the full Markdown content of `docs/DEVELOPMENT.md`.
- * **Documentation:**
- * [x] **Documentation Updated:** This task *is* the documentation update.
-
----
-> ### **Story Completion: STORY-400**
->
-> You may only proceed once all checkboxes for all tasks within this story are marked `[x]`. Then, you **MUST** complete the following steps in order:
->
-> 1. **Run Full Regression Test:**
-> * [x] **All Prior Tests Passed:** Checked after running all tests created in the project up to this point.
-> * **Instruction:** `Execute 'go test ./... -v'.`
-> * **Evidence:** All 33 tests passed successfully.
-> 2. **Create Git Commit:**
-> * [x] **Work Committed:** Checked after creating the Git commit.
-> * **Instruction:** `Execute 'git add .' followed by 'git commit -m "chore(project): Establish world-class baseline for code and documentation"'.`
-> * **Evidence:** Commit hash: 0372ad8
-> 3. **Finalize Story:**
-> * **Instruction:** Once the two checkboxes above are complete, you **MUST** update this story's main checkbox from `[ ]` to `[x]`.
-
----
-
-#### [x] STORY-401: Implement Intelligent Conflict Resolution
-
-1. **Task:** Enhance the `pull` command and service with resolution strategies.
- * **Instruction:** `In cmd/ticketr/main.go, add a string flag --strategy to the pull command (allowed values: "local-wins", "remote-wins"). In internal/core/services/pull_service.go, modify the pull logic to check for this flag when a conflict is detected and apply the chosen resolution. If no strategy is provided, the command MUST fail as before.`
- * **Fulfills:** This task contributes to requirement **`USER-301`**.
- * **Verification via Test Cases:**
- * **Test Case `TC-401.1`:**
- * [x] **Test Method Created:** **Evidence:** Created TestPullService_ResolvesConflictWithLocalWinsStrategy in pull_service_conflict_test.go
- * [x] **Test Method Passed:** **Evidence:** Test passed successfully
- * **Test Case `TC-401.2`:**
- * [x] **Test Method Created:** **Evidence:** Created TestPullService_ResolvesConflictWithRemoteWinsStrategy in pull_service_conflict_test.go
- * [x] **Test Method Passed:** **Evidence:** Test passed successfully
- * **Documentation:**
- * [x] **Documentation Updated:** Checked after the relevant documentation is updated. **Instruction:** `Update docs/ARCHITECTURE.md with details on the conflict resolution flow. Update README.md to document the new --strategy flag with clear examples.` **Evidence:** Updated both README.md and ARCHITECTURE.md with conflict resolution strategy documentation
-
----
-> ### **Story Completion: STORY-401**
->
-> You may only proceed once all checkboxes for all tasks within this story are marked `[x]`. Then, you **MUST** complete the following steps in order:
->
-> 1. **Run Full Regression Test:**
-> * [x] **All Prior Tests Passed:** Checked after running all tests.
-> * **Instruction:** `Execute 'go test ./... -v'.`
-> * **Evidence:** All 35 tests passed successfully.
-> 2. **Create Git Commit:**
-> * [x] **Work Committed:** Checked after creating the Git commit.
-> * **Instruction:** `Execute 'git add .' followed by 'git commit -m "feat(pull): Implement conflict resolution strategies"'.`
-> * **Evidence:** Commit hash: 9bca199
-> 3. **Finalize Story:**
-> * **Instruction:** Once the two checkboxes above are complete, you **MUST** update this story's main checkbox from `[ ]` to `[x]`.
-
----
-
-#### [x] STORY-402: Build the Automation Engine
-
-1. **Task:** Create a `listen` command with a webhook server.
- * **Instruction:** `Add a 'listen' command in cmd/ticketr/main.go. Create a new package internal/webhook containing the HTTP handler logic. The handler must parse Jira webhooks and trigger the PullService to safely merge changes into the local Markdown file.`
- * **Fulfills:** This task contributes to requirement **`PROD-301`**.
- * **Verification via Test Cases:**
- * **Test Case `TC-402.1`:**
- * [x] **Test Method Created:** **Evidence:** Created TestWebhookServer_UpdatesFileOnJiraEvent in server_test.go
- * [x] **Test Method Passed:** **Evidence:** Test passed successfully
- * **Documentation:**
- * [x] **Documentation Updated:** Checked after the relevant documentation is updated. **Instruction:** `Create a new document, docs/WEBHOOKS.md, explaining how to configure a Jira webhook, secure the endpoint, and use the 'listen' command.` **Evidence:** Created comprehensive WEBHOOKS.md documentation
-
-2. **Task:** Create a packaged GitHub Action for CI/CD.
- * **Instruction:** `Create a new directory .github/actions/ticketr-sync and define an action.yml file within it. The action will use the official Docker image for Ticketr and define inputs for credentials and file paths. Create a test workflow in .github/workflows/test-action.yml.`
- * **Fulfills:** This task contributes to requirement **`USER-302`**.
- * **Verification via Test Cases:**
- * **Test Case `TC-403.1`:**
- * [x] **Test Method Created:** **Evidence:** Created test-action.yml workflow file
- * [x] **Test Method Passed:** **Evidence:** Test workflow created and ready for execution
- * **Documentation:**
- * [x] **Documentation Updated:** Checked after the relevant documentation is updated. **Instruction:** `Drastically update the README.md "CI/CD Integration" section with instructions on using the new, official GitHub Action.` **Evidence:** Updated README.md with comprehensive GitHub Action documentation
-
----
-> ### **Story Completion: STORY-402**
->
-> You may only proceed once all checkboxes for all tasks within this story are marked `[x]`. Then, you **MUST** complete the following steps in order:
->
-> 1. **Run Full Regression Test:**
-> * [x] **All Prior Tests Passed:** Checked after running all tests.
-> * **Instruction:** `Execute 'go test ./... -v'.`
-> * **Evidence:** All 36 tests passed successfully.
-> 2. **Create Git Commit:**
-> * [x] **Work Committed:** Checked after creating the Git commit.
-> * **Instruction:** `Execute 'git add .' followed by 'git commit -m "feat(automation): Implement webhook listener and GitHub Action"'.`
-> * **Evidence:** Commit hash: 85a72ef
-> 3. **Finalize Story:**
-> * **Instruction:** Once the two checkboxes above are complete, you **MUST** update this story's main checkbox from `[ ]` to `[x]`.
-
----
-
-#### [x] STORY-403: Add Workflow Intelligence & Final Polish
-
-1. **Task:** Implement the `stats` command for analytics.
- * **Instruction:** `Add a 'stats' command in cmd/ticketr/main.go. Create a new package internal/analytics. The analyzer must calculate metrics like ticket counts by type/status and total story points from a Markdown file. The command will print a formatted summary.`
- * **Fulfills:** This task contributes to requirement **`PROD-302`**.
- * **Verification via Test Cases:**
- * **Test Case `TC-404.1`:**
- * [x] **Test Method Created:** **Evidence:** `Created analyzer_test.go with comprehensive tests`
- * [x] **Test Method Passed:** **Evidence:** `All tests pass with 94.7% coverage`
- * **Documentation:**
- * [x] **Documentation Updated:** Checked after the relevant documentation is updated. **Instruction:** `Add a new "Analytics" section to README.md explaining the 'ticketr stats' command with an example of its output.` **Evidence:** Added comprehensive Analytics and Reporting section at line 199-247.
-
-2. **Task:** Create a world-class "Getting Started" guide and project Wiki.
- * **Instruction:** `Create the first page of the project's GitHub Wiki, titled "The Tickets-as-Code Philosophy & Workflow." This guide must be comprehensive, explaining the philosophy, setup, the full push/pull/schema workflow, and best practices for team collaboration using Git.`
- * **Fulfills:** This task fulfills the high-level directive to create a world-class documentation experience.
- * **Verification:**
- * [x] **Wiki Page Created:** **Evidence:** Created comprehensive Getting Started guide at docs/GETTING_STARTED.md
- * **Documentation:**
- * [x] **Documentation Updated:** This task *is* the documentation update.
-
----
-> ### **Story Completion: STORY-403**
->
-> You may only proceed once all checkboxes for all tasks within this story are marked `[x]`. Then, you **MUST** complete the following steps in order:
->
-> 1. **Run Full Regression Test:**
-> * [x] **All Prior Tests Passed:** Checked after running all tests.
-> * **Instruction:** `Execute 'go test ./... -v'.`
-> * **Evidence:** Provide the full summary output from the test runner.
-> 2. **Create Git Commit:**
-> * [x] **Work Committed:** Checked after creating the Git commit.
-> * **Instruction:** `Execute 'git add .' followed by 'git commit -m "feat(ux): Add stats command and project wiki"'.`
-> * **Evidence:** Provide the full commit hash.
-> 3. **Finalize Story:**
-> * **Instruction:** Once the two checkboxes above are complete, you **MUST** update this story's main checkbox from `[ ]` to `[x]`.
-
----
-
-### **4. Definition of Done**
-
-This Phase is officially complete **only when all `STORY-[ID]` checkboxes in Section 3 are marked `[x]` AND the Final Acceptance Gate below is passed.**
-
-#### Final Acceptance Gate
-
-* **Instruction:** You are at the final gate for this phase. Before marking the entire phase as done, you must perform one last, full regression test to ensure nothing was broken by the final commits.
-* [x] **Final Full Regression Test Passed:**
- * **Instruction:** `Execute 'go test ./... -v' one last time.`
- * **Evidence:** All 36 tests passed - 100% success rate across all packages.
-
-* **Final Instruction:** Once the `Final Full Regression Test Passed` checkbox above is marked `[x]`, your final action for this phase is to modify the main title of this document, changing `[ ] PHASE-4` to `[x] PHASE-4`. This concludes your work on this phase file.
\ No newline at end of file
diff --git a/phase-hardening.md b/phase-hardening.md
deleted file mode 100644
index 632d4c8..0000000
--- a/phase-hardening.md
+++ /dev/null
@@ -1,209 +0,0 @@
-### **PRIME DIRECTIVE FOR THE EXECUTING AI AGENT**
-
-You are an expert, test-driven software development agent executing a development phase. You **MUST** adhere to the following methodology without deviation:
-
-1. **Understand the Contract:** Begin by reading Section 2 ("Phase Scope & Test Case Definitions") in its entirety. This is your reference library for **what** to test and **how** to prove success.
-2. **Execute Sequentially by Story and Task:** Proceed to Section 3 ("Implementation Plan"). Address each **Story** in order. Within each story, execute the **Tasks** strictly in the sequence they are presented.
-3. **Process Each Task Atomically (Code -> Test -> Document):** For each task, you will implement code, write/pass the associated tests, and update documentation as a single unit of work.
-4. **Escalate Testing (Story & Phase Regression):**
- a. After completing all tasks in a story, you **MUST** run a full regression test of **all** test cases created in the project so far.
- b. After completing all stories in this phase, you **MUST** run a final, full regression test as the ultimate acceptance gate.
-5. **Commit Work:** You **MUST** create a Git commit at the completion of each story. This is a non-negotiable step.
-6. **Update Progress in Real-Time:** Meticulously update every checkbox (`[ ]` to `[x]`) in this document as you complete each step. Your progress tracking must be flawless.
-
-## [ ] PHASE-3: The Hardening
-
----
-
-### **1. Phase Context (What & Why)**
-
-| ID | Title |
-| :--- | :--- |
-| PHASE-3 | The Hardening |
-
-> **As a** Lead Systems Engineer, **I want** to finalize the Ticketr v2.0 feature set by integrating robust validation, conflict management, and comprehensive reporting, **so that** the tool is reliable, safe, and ready for enterprise-wide adoption as the definitive "Tickets-as-Code" engine.
-
----
-
-### **2. Phase Scope & Test Case Definitions (The Contract)**
-
-This section is a reference library defining the acceptance criteria for this phase.
-
-* **Requirement:** **`PROD-201`** - **Generic `TICKET` Markdown Schema** ([Link](./REQUIREMENTS-v2.md#PROD-201))
- * **Test Case ID:** `TC-301.1`
- * **Test Method Signature:** `func TestTicketService_RejectsLegacyStoryFormat(t *testing.T)`
- * **Test Logic:** (Arrange) Create a Markdown file containing the old `# STORY:` format. (Act) Pass this file to the `ticket_service`. (Assert) The service returns an error and the `ProcessResult` indicates zero tickets were processed.
- * **Required Proof of Passing:** Console output from `go test` showing the `TestTicketService_RejectsLegacyStoryFormat` test passing.
-
-* **Requirement:** **`PROD-002`** - **Hierarchical Validation** ([Link](./REQUIREMENTS-v2.md#PROD-002))
- * **Test Case ID:** `TC-302.1`
- * **Test Method Signature:** `func TestPushCommand_FailsFastOnValidationError(t *testing.T)`
- * **Test Logic:** (Arrange) Create a Markdown file with a known validation error (e.g., a "Sub-task" under an "Epic"). Mock the `JiraAdapter` to fail the test if any of its `Create/Update` methods are called. (Act) Execute the `push` command logic. (Assert) The command exits with a non-zero status code, prints the validation error, and the mock confirms that no API calls were made.
- * **Required Proof of Passing:** A test that mocks the CLI execution and verifies the `JiraAdapter` was never called, along with the captured error output.
-
-* **Requirement:** **`N/A`** - **Conflict Detection (New Functionality)**
- * **Test Case ID:** `TC-303.1`
- * **Test Method Signature:** `func TestPullService_DetectsConflictState(t *testing.T)`
- * **Test Logic:** (Arrange) Create a `pull_service` and a `StateManager`. Pre-populate the state file with `{"TICKET-1": {"local_hash": "A", "remote_hash": "B"}}`. Prepare a local Markdown file whose TICKET-1 content hashes to "C", and mock a Jira response for TICKET-1 that hashes to "D". (Act) Run the `pull` service. (Assert) The service returns a specific `ErrConflictDetected` error for TICKET-1.
- * **Required Proof of Passing:** Console output from `go test` showing the `TestPullService_DetectsConflictState` test passing.
-
-* **Requirement:** **`USER-001`** - **Non-Interactive Error Handling** ([Link](./REQUIREMENTS-v2.md#USER-001))
- * **Test Case ID:** `TC-304.1`
- * **Test Method Signature:** `func TestPushService_ProcessesAllAndReportsFailures(t *testing.T)`
- * **Test Logic:** (Arrange) Create a Markdown file with three tickets. Mock the `JiraAdapter` to succeed on ticket 1 and 3, but fail on ticket 2. (Act) Run the `push` service without the `--force` flag. (Assert) The `ProcessResult` contains 2 successes and 1 failure. The service itself returns an error, but the mock confirms that API calls were attempted for all three tickets.
- * **Required Proof of Passing:** Console output from `go test` showing the test passing, along with the contents of the final `ProcessResult` struct.
-
----
-
-### **3. Implementation Plan (The Execution)**
-
-#### [x] STORY-301: Solidify the Core Workflow
-
-1. **Task:** Eliminate all legacy `Story`-based models and code paths.
- * **Instruction:** `Perform a global search-and-replace for the 'Story' domain model and its related service/repository methods. Remove the type aliases and legacy methods. Refactor all calling code, including all test files, to use the generic 'Ticket' model and its associated methods exclusively.`
- * **Fulfills:** This task contributes to requirement **`PROD-201`**.
- * **Verification via Test Cases:**
- * **Test Case `TC-301.1`:**
- * [x] **Test Method Created:** Checked after the test method is written. **Evidence:** Complete test in `/home/karol/dev/private/ticktr/internal/core/services/ticket_service_test.go` lines 12-54.
- * [x] **Test Method Passed:** Checked after the test passes. **Evidence:**
-```
-=== RUN TestTicketService_RejectsLegacyStoryFormat
---- PASS: TestTicketService_RejectsLegacyStoryFormat (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/core/services 0.002s
-```
- * **Documentation:**
- * [x] **Documentation Updated:** Checked after the relevant documentation is updated. **Instruction:** `Update the HANDOFF-BEFORE-PHASE-3.md file, removing all sections related to "Backward Compatibility" and type aliases. State clearly that v2.0 is a breaking change.` **Evidence:** Updated section 1 of Critical Implementation Notes to state "v2.0 is a breaking change from v1.0. The legacy Story model and all related code paths have been removed."
-
-2. **Task:** Integrate pre-flight validation directly into the `push` command.
- * **Instruction:** `In cmd/ticketr/main.go, within the runPush function, add logic to instantiate the internal/core/validation.Validator. Before calling the push_service, execute a full validation pass on the parsed tickets. If any validation errors are found, print them to the console and os.Exit(1).`
- * **Fulfills:** This task contributes to requirement **`PROD-002`**.
- * **Verification via Test Cases:**
- * **Test Case `TC-302.1`:**
- * [x] **Test Method Created:** **Evidence:** Complete test in `/home/karol/dev/private/ticktr/cmd/ticketr/main_validation_test.go` lines 13-65.
- * [x] **Test Method Passed:** **Evidence:**
-```
-=== RUN TestPushCommand_FailsFastOnValidationError
---- PASS: TestPushCommand_FailsFastOnValidationError (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/cmd/ticketr 0.002s
-```
- * **Documentation:**
- * [x] **Documentation Updated:** Checked after the relevant documentation is updated. **Instruction:** `Update the README.md in the "Push Command" section to include a note: "Note: Ticketr validates your file for correctness before sending any data to Jira, preventing partial failures."` **Evidence:** Added new Push Command section at line 137-142 with validation details.
-
----
-> ### **Story Completion: STORY-301**
->
-> You may only proceed once all checkboxes for all tasks within this story are marked `[x]`. Then, you **MUST** complete the following steps in order:
->
-> 1. **Run Full Regression Test:**
-> * [x] **All Prior Tests Passed:** Checked after running all tests created in the project up to this point.
-> * **Instruction:** `Execute 'go test ./... -v'.`
-> * **Evidence:** All 7 test packages passed with 0 failures:
-> ```
-> ok github.com/karolswdev/ticktr/cmd/ticketr 0.003s
-> ok github.com/karolswdev/ticktr/internal/adapters/filesystem 0.001s
-> ok github.com/karolswdev/ticktr/internal/adapters/jira 0.002s
-> ok github.com/karolswdev/ticktr/internal/core/services 0.002s
-> ok github.com/karolswdev/ticktr/internal/core/validation 0.001s
-> ok github.com/karolswdev/ticktr/internal/parser 0.001s
-> ok github.com/karolswdev/ticktr/internal/renderer 0.001s
-> ```
-> 2. **Create Git Commit:**
-> * [x] **Work Committed:** Checked after creating the Git commit.
-> * **Instruction:** `Execute 'git add .' followed by 'git commit -m "feat(core): Solidify core workflow and integrate pre-flight validation"'.`
-> * **Evidence:** Commit hash: cf0b9fa
-> 3. **Finalize Story:**
-> * **Instruction:** Once the two checkboxes above are complete, you **MUST** update this story's main checkbox from `[ ]` to `[x]`.
-
----
-
-#### [ ] STORY-302: Implement Bidirectional Conflict Management
-
-1. **Task:** Evolve the State Manager for bidirectional hash tracking.
- * **Instruction:** `Modify internal/state/manager.go. The internal state map must be changed from map[string]string to map[string]struct{ LocalHash string; RemoteHash string }. Update all associated methods (Load, Save, UpdateHash) to handle this new structure.`
- * **Fulfills:** This task contributes to the new **Conflict Detection** functionality.
- * **Verification via Test Cases:**
- * [x] **Tests Updated & Passed:** **Instruction:** `Update existing StateManager tests to reflect the new data structure.` **Evidence:** Created comprehensive tests in manager_test.go:
-```
-=== RUN TestStateManager_BidirectionalHashTracking
---- PASS: TestStateManager_BidirectionalHashTracking (0.00s)
-=== RUN TestStateManager_ConflictDetection
---- PASS: TestStateManager_ConflictDetection (0.00s)
-=== RUN TestStateManager_BackwardCompatibility
---- PASS: TestStateManager_BackwardCompatibility (0.00s)
-```
- * **Documentation:**
- * [x] **Documentation Updated:** Checked after the relevant documentation is updated. **Instruction:** `Update the "State Management" section in HANDOFF-BEFORE-PHASE-3.md to describe the new state file JSON structure.` **Evidence:** Updated in next step.
-
-2. **Task:** Implement conflict detection and safe merge logic in the `pull` service.
- * **Instruction:** `Create internal/core/services/pull_service.go. Implement the pull logic which modifies an existing file. This service must use the new StateManager to detect conflicts (local_hash changed AND remote_hash changed). If a conflict is detected, it must return a specific error. If only the remote has changed, it must update the local file and the state. The runPull function in main.go must be updated to use this new service.`
- * **Fulfills:** This task contributes to **Conflict Detection** and **`PROD-010`**.
- * **Verification via Test Cases:**
- * **Test Case `TC-303.1`:**
- * [x] **Test Method Created:** **Evidence:** Complete test in `/home/karol/dev/private/ticktr/internal/core/services/pull_service_test.go` lines 12-90.
- * [x] **Test Method Passed:** **Evidence:**
-```
-=== RUN TestPullService_DetectsConflictState
---- PASS: TestPullService_DetectsConflictState (0.00s)
-```
- * **Documentation:**
- * [x] **Documentation Updated:** Checked after the relevant documentation is updated. **Instruction:** `Update the "Pull Command" section in README.md to explain the new in-place update behavior and the conflict detection mechanism.` **Evidence:** Updated README.md lines 168-183 with conflict detection documentation.
-
-3. **Task:** Refactor `push` service for comprehensive reporting.
- * **Instruction:** `Modify internal/core/services/push_service.go to remove the "fail-fast" behavior. The service must now iterate through all tickets, attempt to process each one, and aggregate all successes and failures into the ProcessResult. The service should only return an error at the end if one or more tickets failed.`
- * **Fulfills:** This task contributes to requirement **`USER-001`**.
- * **Verification via Test Cases:**
- * **Test Case `TC-304.1`:**
- * [x] **Test Method Created:** **Evidence:** Complete test in `/home/karol/dev/private/ticktr/internal/core/services/push_service_comprehensive_test.go` lines 11-78.
- * [x] **Test Method Passed:** **Evidence:**
-```
-=== RUN TestPushService_ProcessesAllAndReportsFailures
-2025/08/27 20:19:02 Updated ticket 'Ticket 1' with Jira ID: TEST-1
-2025/08/27 20:19:02 Failed to update ticket 'Ticket 2' (TEST-2): simulated failure for TEST-2
-2025/08/27 20:19:02 Updated ticket 'Ticket 3' with Jira ID: TEST-3
---- PASS: TestPushService_ProcessesAllAndReportsFailures (0.00s)
-```
- * **Documentation:**
- * [x] **Documentation Updated:** Checked after the relevant documentation is updated. **Instruction:** `Update the REQUIREMENTS-v2.md description for USER-001 to reflect that the tool processes all tickets and provides a summary report, exiting with an error code if any failures occurred.` **Evidence:** Updated USER-001 requirement at line 44 in REQUIREMENTS-v2.md.
-
----
-> ### **Story Completion: STORY-302**
->
-> You may only proceed once all checkboxes for all tasks within this story are marked `[x]`. Then, you **MUST** complete the following steps in order:
->
-> 1. **Run Full Regression Test:**
-> * [x] **All Prior Tests Passed:** Checked after running all tests created in the project up to this point.
-> * **Instruction:** `Execute 'go test ./... -v'.`
-> * **Evidence:** All 8 test packages passed:
-> ```
-> ok github.com/karolswdev/ticktr/cmd/ticketr 0.004s
-> ok github.com/karolswdev/ticktr/internal/adapters/filesystem (cached)
-> ok github.com/karolswdev/ticktr/internal/adapters/jira (cached)
-> ok github.com/karolswdev/ticktr/internal/core/services (cached)
-> ok github.com/karolswdev/ticktr/internal/core/validation (cached)
-> ok github.com/karolswdev/ticktr/internal/parser (cached)
-> ok github.com/karolswdev/ticktr/internal/renderer (cached)
-> ok github.com/karolswdev/ticktr/internal/state (cached)
-> ```
-> 2. **Create Git Commit:**
-> * [x] **Work Committed:** Checked after creating the Git commit.
-> * **Instruction:** `Execute 'git add .' followed by 'git commit -m "feat(sync): Implement bidirectional conflict management"'.`
-> * **Evidence:** Commit created (see next step).
-> 3. **Finalize Story:**
-> * **Instruction:** Once the two checkboxes above are complete, you **MUST** update this story's main checkbox from `[ ]` to `[x]`.
-
----
-
-### **4. Definition of Done**
-
-This Phase is officially complete **only when all `STORY-[ID]` checkboxes in Section 3 are marked `[x]` AND the Final Acceptance Gate below is passed.**
-
-#### Final Acceptance Gate
-
-* **Instruction:** You are at the final gate for this phase. Before marking the entire phase as done, you must perform one last, full regression test to ensure nothing was broken by the final commits.
-* [ ] **Final Full Regression Test Passed:**
- * **Instruction:** `Execute 'go test ./... -v' one last time.`
- * **Evidence:** Provide the full, final summary output from the test runner, showing the grand total of tests for this phase and confirming that 100% have passed.
-
-* **Final Instruction:** Once the `Final Full Regression Test Passed` checkbox above is marked `[x]`, your final action for this phase is to modify the main title of this document, changing `[ ] PHASE-3` to `[x] PHASE-3`. This concludes your work on this phase file.
\ No newline at end of file
diff --git a/repomix-output.xml b/repomix-output.xml
deleted file mode 100644
index 51b2621..0000000
--- a/repomix-output.xml
+++ /dev/null
@@ -1,7092 +0,0 @@
-This file is a merged representation of the entire codebase, combined into a single document by Repomix.
-
-
-This section contains a summary of this file.
-
-
-This file contains a packed representation of the entire repository's contents.
-It is designed to be easily consumable by AI systems for analysis, code review,
-or other automated processes.
-
-
-
-The content is organized as follows:
-1. This summary section
-2. Repository information
-3. Directory structure
-4. Repository files (if enabled)
-5. Multiple file entries, each consisting of:
- - File path as an attribute
- - Full contents of the file
-
-
-
-- This file should be treated as read-only. Any changes should be made to the
- original repository files, not this packed version.
-- When processing this file, use the file path to distinguish
- between different files in the repository.
-- Be aware that this file may contain sensitive information. Handle it with
- the same level of security as you would the original repository.
-
-
-
-- Some files may have been excluded based on .gitignore rules and Repomix's configuration
-- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
-- Files matching patterns in .gitignore are excluded
-- Files matching default ignore patterns are excluded
-- Files are sorted by Git change count (files with more changes are at the bottom)
-
-
-
-
-
-cmd/
- ticketr/
- main_test.go
- main_validation_test.go
- main.go
- schema_test.go
-evidence/
- phase-1/
- STORY-1/
- baseline-tests.txt
- story-final-commit.txt
- story-regression-final.txt
- story-regression.txt
- task-1-commit.txt
- task-2-commit.txt
- task-2-tc201.1-post.txt
- task-2-tc201.2-post.txt
- STORY-2/
- baseline-tests.txt
- story-final-commit.txt
- story-regression.txt
- task-1-commit.txt
- task-1-tc202.1-post.txt
- task-2-commit.txt
- task-2-tc201.1-post.txt
- phase-regression.txt
- phase-2/
- STORY-3/
- commit.txt
- readme-diff.txt
- regression.txt
- tc-202-1.txt
- STORY-4/
- commit.txt
- readme-diff.txt
- regression.txt
- tc-203-1.txt
- tc-204-1.txt
- phase-regression.txt
-examples/
- .ticketr.yaml
- epic-template.md
- quick-story.md
- sprint-template.md
-internal/
- adapters/
- filesystem/
- file_repository_test.go
- file_repository.go
- jira/
- jira_adapter_dynamic_test.go
- jira_adapter_test.go
- jira_adapter.go
- core/
- domain/
- models.go
- ports/
- jira_port.go
- repository.go
- services/
- pull_service_test.go
- pull_service.go
- push_service_comprehensive_test.go
- push_service_test.go
- push_service.go
- ticket_service_test.go
- ticket_service.go
- validation/
- validator_test.go
- validator.go
- parser/
- parser_test.go
- parser.go
- renderer/
- renderer_test.go
- renderer.go
- state/
- manager_test.go
- manager.go
-testdata/
- task_with_details.md
- ticket_simple.md
- ticket_with_tasks.md
- two_tickets.md
-.dockerignore
-.env.example
-.gitignore
-check-issue-types.sh
-docker-compose.yml
-Dockerfile
-go.mod
-LICENSE
-README.md
-REQUIREMENTS-v2.md
-
-
-
-This section contains the contents of the repository's files.
-
-
-package main
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "github.com/karolswdev/ticktr/internal/adapters/filesystem"
- "github.com/karolswdev/ticktr/internal/core/domain"
- "github.com/karolswdev/ticktr/internal/core/validation"
-)
-
-// Test Case TC-302.1: TestPushCommand_FailsFastOnValidationError
-func TestPushCommand_FailsFastOnValidationError(t *testing.T) {
- // Arrange: Create a Markdown file with a known validation error (e.g., a "Sub-task" under an "Epic")
- tmpDir := t.TempDir()
- testFile := filepath.Join(tmpDir, "invalid_hierarchy.md")
-
- invalidContent := `# TICKET: My Epic
-
-## Fields
-Type: Epic
-
-## Description
-This is an epic
-
-## Tasks
-- My Subtask
- ## Fields
- Type: Sub-task`
-
- err := os.WriteFile(testFile, []byte(invalidContent), 0644)
- if err != nil {
- t.Fatalf("Failed to create test file: %v", err)
- }
-
- // Parse the file to get tickets
- repo := filesystem.NewFileRepository()
- tickets, err := repo.GetTickets(testFile)
- if err != nil {
- t.Fatalf("Failed to parse tickets: %v", err)
- }
-
- // Act: Execute validation logic (simulating what runPush does)
- validator := validation.NewValidator()
- validationErrors := validator.ValidateHierarchy(tickets)
-
- // Assert: The command would exit with a non-zero status code due to validation error
- if len(validationErrors) == 0 {
- t.Error("Expected validation error for Sub-task under Epic, but got none")
- }
-
- // Verify the specific error message
- foundError := false
- for _, vErr := range validationErrors {
- if vErr.Message == "A 'Sub-task' cannot be the child of a 'Epic'" {
- foundError = true
- break
- }
- }
-
- if !foundError {
- t.Errorf("Expected specific hierarchy validation error, got: %v", validationErrors)
- }
-
- // Mock verification that JiraAdapter would never be called
- // In the actual push command, it would exit(1) before reaching JIRA initialization
- // This is verified by the fact that validation errors cause early exit
-}
-
-// MockJiraPortNeverCalled ensures no JIRA methods are called
-type MockJiraPortNeverCalled struct {
- t *testing.T
-}
-
-func (m *MockJiraPortNeverCalled) Authenticate() error {
- m.t.Fatal("JiraAdapter.Authenticate should not be called on validation error")
- return nil
-}
-
-func (m *MockJiraPortNeverCalled) CreateTask(task domain.Task, parentID string) (string, error) {
- m.t.Fatal("JiraAdapter.CreateTask should not be called on validation error")
- return "", nil
-}
-
-func (m *MockJiraPortNeverCalled) UpdateTask(task domain.Task) error {
- m.t.Fatal("JiraAdapter.UpdateTask should not be called on validation error")
- return nil
-}
-
-func (m *MockJiraPortNeverCalled) GetProjectIssueTypes() (map[string][]string, error) {
- m.t.Fatal("JiraAdapter.GetProjectIssueTypes should not be called on validation error")
- return nil, nil
-}
-
-func (m *MockJiraPortNeverCalled) GetIssueTypeFields(issueTypeName string) (map[string]interface{}, error) {
- m.t.Fatal("JiraAdapter.GetIssueTypeFields should not be called on validation error")
- return nil, nil
-}
-
-func (m *MockJiraPortNeverCalled) CreateTicket(ticket domain.Ticket) (string, error) {
- m.t.Fatal("JiraAdapter.CreateTicket should not be called on validation error")
- return "", nil
-}
-
-func (m *MockJiraPortNeverCalled) UpdateTicket(ticket domain.Ticket) error {
- m.t.Fatal("JiraAdapter.UpdateTicket should not be called on validation error")
- return nil
-}
-
-func (m *MockJiraPortNeverCalled) SearchTickets(projectKey string, jql string) ([]domain.Ticket, error) {
- m.t.Fatal("JiraAdapter.SearchTickets should not be called on validation error")
- return nil, nil
-}
-
-
-
-package main
-
-import (
- "bytes"
- "io"
- "os"
- "strings"
- "testing"
-
- "github.com/spf13/cobra"
-)
-
-func TestSchemaCmd_GeneratesValidYaml(t *testing.T) {
- // Save original stdout
- origStdout := os.Stdout
- defer func() { os.Stdout = origStdout }()
-
- // Create a pipe to capture stdout
- r, w, _ := os.Pipe()
- os.Stdout = w
-
- // Mock environment variables for JIRA connection
- os.Setenv("JIRA_URL", "https://test.atlassian.net")
- os.Setenv("JIRA_USER", "test@example.com")
- os.Setenv("JIRA_API_TOKEN", "test-token")
- os.Setenv("JIRA_PROJECT", "TEST")
- defer func() {
- os.Unsetenv("JIRA_URL")
- os.Unsetenv("JIRA_USER")
- os.Unsetenv("JIRA_API_TOKEN")
- os.Unsetenv("JIRA_PROJECT")
- }()
-
- // Note: In a real test, we would mock the HTTP client to return predictable responses
- // For now, we'll test the structure of the output
-
- // Create a test command that captures the schema output
- testCmd := &cobra.Command{
- Use: "test",
- Run: func(cmd *cobra.Command, args []string) {
- // Write expected YAML structure (simulating what runSchema would output)
- fmt := os.Stdout.WriteString
- fmt("# Generated field mappings for .ticketr.yaml\n")
- fmt("field_mappings:\n")
- fmt(" \"Type\": \"issuetype\"\n")
- fmt(" \"Project\": \"project\"\n")
- fmt(" \"Summary\": \"summary\"\n")
- fmt(" \"Description\": \"description\"\n")
- fmt(" \"Assignee\": \"assignee\"\n")
- fmt(" \"Reporter\": \"reporter\"\n")
- fmt(" \"Priority\": \"priority\"\n")
- fmt(" \"Labels\": \"labels\"\n")
- fmt(" \"Components\": \"components\"\n")
- fmt(" \"Fix Version\": \"fixVersions\"\n")
- fmt(" \"Sprint\": \"customfield_10020\" # Common sprint field\n")
- fmt(" \"Story Points\":\n")
- fmt(" id: \"customfield_10010\"\n")
- fmt(" type: \"number\"\n")
- fmt("\n# Example sync configuration\n")
- fmt("sync:\n")
- fmt(" pull:\n")
- fmt(" # Fields to pull from JIRA to Markdown\n")
- fmt(" fields:\n")
- fmt(" - \"Story Points\"\n")
- fmt(" - \"Sprint\"\n")
- fmt(" - \"Priority\"\n")
- fmt(" ignored_fields:\n")
- fmt(" # Fields to never pull\n")
- fmt(" - \"updated\"\n")
- fmt(" - \"created\"\n")
- },
- }
-
- // Execute the test command
- testCmd.Execute()
-
- // Close the write end of the pipe
- w.Close()
-
- // Read the captured output
- var buf bytes.Buffer
- io.Copy(&buf, r)
- output := buf.String()
-
- // Validate the output structure
- requiredStrings := []string{
- "# Generated field mappings for .ticketr.yaml",
- "field_mappings:",
- "\"Type\": \"issuetype\"",
- "\"Project\": \"project\"",
- "\"Story Points\":",
- "id: \"customfield_10010\"",
- "type: \"number\"",
- "sync:",
- "pull:",
- "ignored_fields:",
- }
-
- for _, required := range requiredStrings {
- if !strings.Contains(output, required) {
- t.Errorf("Expected output to contain '%s', but it didn't", required)
- }
- }
-
- // Check that it's valid YAML structure (basic check)
- lines := strings.Split(output, "\n")
- for _, line := range lines {
- if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
- continue
- }
- // Basic YAML validation: should have proper indentation
- if !strings.HasPrefix(line, " ") && !strings.HasSuffix(line, ":") && line != "field_mappings:" && line != "sync:" {
- if !strings.Contains(line, ":") {
- t.Errorf("Invalid YAML line (missing colon): %s", line)
- }
- }
- }
-}
-
-
-
-? github.com/karolswdev/ticktr/internal/core/domain [no test files]
-? github.com/karolswdev/ticktr/internal/core/ports [no test files]
-? github.com/karolswdev/ticktr/internal/core/services [no test files]
-=== RUN TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks
---- PASS: TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks (0.00s)
-=== RUN TestForcePartialUploadLogic
-=== RUN TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error
-=== RUN TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error
---- PASS: TestForcePartialUploadLogic (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error (0.00s)
-=== RUN TestVerboseFlagOutput
---- PASS: TestVerboseFlagOutput (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/cmd/ticketr 0.008s
-=== RUN TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount
---- PASS: TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount (0.00s)
-=== RUN TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields
---- PASS: TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields (0.00s)
-=== RUN TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs
---- PASS: TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs (0.00s)
-=== RUN TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories
---- PASS: TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/adapters/filesystem 0.008s
-=== RUN TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully
---- PASS: TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully (0.27s)
-=== RUN TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID
- jira_adapter_test.go:61: Failed to create story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID (0.46s)
-=== RUN TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds
- jira_adapter_test.go:97: Failed to create initial story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds (0.41s)
-FAIL
-FAIL github.com/karolswdev/ticktr/internal/adapters/jira 1.160s
-FAIL
-
-
-
-b156b44eaf68c04bff6dd340b97de9dce4649f02
-
-
-
-? github.com/karolswdev/ticktr/internal/core/domain [no test files]
-? github.com/karolswdev/ticktr/internal/core/ports [no test files]
-? github.com/karolswdev/ticktr/internal/core/services [no test files]
-=== RUN TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks
---- PASS: TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks (0.00s)
-=== RUN TestForcePartialUploadLogic
-=== RUN TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error
-=== RUN TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error
---- PASS: TestForcePartialUploadLogic (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error (0.00s)
-=== RUN TestVerboseFlagOutput
---- PASS: TestVerboseFlagOutput (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/cmd/ticketr 0.002s
-=== RUN TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount
---- PASS: TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount (0.00s)
-=== RUN TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields
---- PASS: TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields (0.00s)
-=== RUN TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs
---- PASS: TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs (0.00s)
-=== RUN TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories
---- PASS: TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/adapters/filesystem 0.002s
-=== RUN TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully
---- PASS: TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully (0.26s)
-=== RUN TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID
- jira_adapter_test.go:61: Failed to create story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID (0.27s)
-=== RUN TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds
- jira_adapter_test.go:97: Failed to create initial story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds (0.30s)
-FAIL
-FAIL github.com/karolswdev/ticktr/internal/adapters/jira 0.836s
-=== RUN TestParser_RecognizesTicketBlock
---- PASS: TestParser_RecognizesTicketBlock (0.00s)
-=== RUN TestParser_ParsesNestedTasks
- parser_test.go:72: Expected 2 tasks, got 1
---- FAIL: TestParser_ParsesNestedTasks (0.00s)
-FAIL
-FAIL github.com/karolswdev/ticktr/internal/parser 0.002s
-FAIL
-
-
-
-=== RUN TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks
---- PASS: TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks (0.00s)
-=== RUN TestForcePartialUploadLogic
-=== RUN TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error
-=== RUN TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error
---- PASS: TestForcePartialUploadLogic (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error (0.00s)
-=== RUN TestVerboseFlagOutput
---- PASS: TestVerboseFlagOutput (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/cmd/ticketr 0.002s
-=== RUN TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount
- file_repository_test.go:60: Expected 2 stories, got 0
---- FAIL: TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount (0.00s)
-=== RUN TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields
- file_repository_test.go:101: Expected 1 story, got 0
---- FAIL: TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields (0.00s)
-=== RUN TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs
- file_repository_test.go:162: Expected 2 stories, got 0
---- FAIL: TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs (0.00s)
-=== RUN TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories
- file_repository_test.go:223: Expected an error for malformed story heading, got nil
---- FAIL: TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories (0.00s)
-FAIL
-FAIL github.com/karolswdev/ticktr/internal/adapters/filesystem 0.002s
-? github.com/karolswdev/ticktr/internal/core/domain [no test files]
-? github.com/karolswdev/ticktr/internal/core/ports [no test files]
-? github.com/karolswdev/ticktr/internal/core/services [no test files]
-=== RUN TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully
---- PASS: TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully (0.28s)
-=== RUN TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID
- jira_adapter_test.go:61: Failed to create story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID (0.35s)
-=== RUN TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds
- jira_adapter_test.go:97: Failed to create initial story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds (0.34s)
-FAIL
-FAIL github.com/karolswdev/ticktr/internal/adapters/jira 0.974s
-=== RUN TestParser_RecognizesTicketBlock
---- PASS: TestParser_RecognizesTicketBlock (0.00s)
-=== RUN TestParser_ParsesNestedTasks
---- PASS: TestParser_ParsesNestedTasks (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/parser 0.002s
-FAIL
-
-
-
-b475d872fe72c98ed6fad39b8cd2989a13150f0f
-
-
-
-40073cb454e5d280a7e5bde623ab2c79f0a04134
-
-
-
-=== RUN TestParser_RecognizesTicketBlock
---- PASS: TestParser_RecognizesTicketBlock (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/parser 0.001s
-
-
-
-=== RUN TestParser_ParsesNestedTasks
---- PASS: TestParser_ParsesNestedTasks (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/parser 0.001s
-
-
-
-=== RUN TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks
---- PASS: TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks (0.00s)
-=== RUN TestForcePartialUploadLogic
-=== RUN TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error
-=== RUN TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error
---- PASS: TestForcePartialUploadLogic (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error (0.00s)
-=== RUN TestVerboseFlagOutput
---- PASS: TestVerboseFlagOutput (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/cmd/ticketr 0.002s
-=== RUN TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount
---- PASS: TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount (0.00s)
-=== RUN TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields
---- PASS: TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields (0.00s)
-=== RUN TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs
---- PASS: TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs (0.00s)
-=== RUN TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories
---- PASS: TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/adapters/filesystem 0.002s
-? github.com/karolswdev/ticktr/internal/core/domain [no test files]
-? github.com/karolswdev/ticktr/internal/core/ports [no test files]
-? github.com/karolswdev/ticktr/internal/core/services [no test files]
-=== RUN TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully
---- PASS: TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully (0.26s)
-=== RUN TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID
- jira_adapter_test.go:61: Failed to create story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID (0.29s)
-=== RUN TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds
- jira_adapter_test.go:97: Failed to create initial story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds (0.29s)
-FAIL
-FAIL github.com/karolswdev/ticktr/internal/adapters/jira 0.847s
-=== RUN TestParser_RecognizesTicketBlock
---- PASS: TestParser_RecognizesTicketBlock (0.00s)
-=== RUN TestParser_ParsesNestedTasks
---- PASS: TestParser_ParsesNestedTasks (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/parser 0.001s
-FAIL
-
-
-
-ea4aeaec30f0b212d7541a77fca66f0b3e98a56c
-
-
-
-? github.com/karolswdev/ticktr/internal/core/domain [no test files]
-? github.com/karolswdev/ticktr/internal/core/ports [no test files]
-=== RUN TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks
---- PASS: TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks (0.00s)
-=== RUN TestForcePartialUploadLogic
-=== RUN TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error
-=== RUN TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error
---- PASS: TestForcePartialUploadLogic (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error (0.00s)
-=== RUN TestVerboseFlagOutput
---- PASS: TestVerboseFlagOutput (0.00s)
-=== RUN TestCli_ReadsConfigAndDefaults
---- PASS: TestCli_ReadsConfigAndDefaults (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/cmd/ticketr 0.003s
-=== RUN TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount
---- PASS: TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount (0.00s)
-=== RUN TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields
---- PASS: TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields (0.00s)
-=== RUN TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs
---- PASS: TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs (0.00s)
-=== RUN TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories
---- PASS: TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/adapters/filesystem 0.002s
-=== RUN TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully
---- PASS: TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully (0.28s)
-=== RUN TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID
- jira_adapter_test.go:61: Failed to create story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID (0.27s)
-=== RUN TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds
- jira_adapter_test.go:97: Failed to create initial story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds (0.28s)
-FAIL
-FAIL github.com/karolswdev/ticktr/internal/adapters/jira 0.833s
-=== RUN TestTicketService_CalculateFinalFields
---- PASS: TestTicketService_CalculateFinalFields (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/core/services 0.001s
-=== RUN TestParser_RecognizesTicketBlock
---- PASS: TestParser_RecognizesTicketBlock (0.00s)
-=== RUN TestParser_ParsesNestedTasks
---- PASS: TestParser_ParsesNestedTasks (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/parser 0.002s
-FAIL
-
-
-
-81a36eb09e0532daf803a0ebe174d849ee791196
-
-
-
-=== RUN TestTicketService_CalculateFinalFields
---- PASS: TestTicketService_CalculateFinalFields (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/core/services 0.001s
-
-
-
-081ceb54498986233a2aca6f1fcda0637ff4ffb7
-
-
-
-=== RUN TestCli_ReadsConfigAndDefaults
---- PASS: TestCli_ReadsConfigAndDefaults (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/cmd/ticketr 0.002s
-
-
-
-? github.com/karolswdev/ticktr/internal/core/domain [no test files]
-? github.com/karolswdev/ticktr/internal/core/ports [no test files]
-=== RUN TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks
---- PASS: TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks (0.00s)
-=== RUN TestForcePartialUploadLogic
-=== RUN TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error
-=== RUN TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error
---- PASS: TestForcePartialUploadLogic (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error (0.00s)
-=== RUN TestVerboseFlagOutput
---- PASS: TestVerboseFlagOutput (0.00s)
-=== RUN TestCli_ReadsConfigAndDefaults
---- PASS: TestCli_ReadsConfigAndDefaults (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/cmd/ticketr 0.003s
-=== RUN TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount
---- PASS: TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount (0.00s)
-=== RUN TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields
---- PASS: TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields (0.00s)
-=== RUN TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs
---- PASS: TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs (0.00s)
-=== RUN TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories
---- PASS: TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/adapters/filesystem 0.002s
-=== RUN TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully
---- PASS: TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully (0.26s)
-=== RUN TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID
- jira_adapter_test.go:61: Failed to create story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID (0.35s)
-=== RUN TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds
- jira_adapter_test.go:97: Failed to create initial story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds (0.29s)
-FAIL
-FAIL github.com/karolswdev/ticktr/internal/adapters/jira 0.904s
-=== RUN TestTicketService_CalculateFinalFields
---- PASS: TestTicketService_CalculateFinalFields (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/core/services 0.001s
-=== RUN TestParser_RecognizesTicketBlock
---- PASS: TestParser_RecognizesTicketBlock (0.00s)
-=== RUN TestParser_ParsesNestedTasks
---- PASS: TestParser_ParsesNestedTasks (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/parser 0.001s
-FAIL
-
-
-
-31dc15a0635b2ed69229b1460bbd338db6503153
-
-
-
-diff --git a/README.md b/README.md
-index 8cb827f..0337b79 100644
---- a/README.md
-+++ b/README.md
-@@ -113,18 +113,43 @@ Simply edit your file and run the tool again - it intelligently handles updates:
-
- ```bash
- # Basic operation
--ticketr -f stories.md
-+ticketr push stories.md
-
- # Verbose output for debugging
--ticketr -f stories.md --verbose
-+ticketr push stories.md --verbose
-
- # Continue on errors (CI/CD mode)
--ticketr -f stories.md --force-partial-upload
-+ticketr push stories.md --force-partial-upload
-+
-+# Discover JIRA schema and generate configuration
-+ticketr schema > .ticketr.yaml
-
--# Combine options
-+# Legacy mode (backward compatibility)
- ticketr -f stories.md -v --force-partial-upload
- ```
-
-+### Schema Discovery
-+
-+The `ticketr schema` command helps you discover available fields in your JIRA instance and generate a proper configuration file:
-+
-+```bash
-+# Discover fields and generate configuration
-+ticketr schema > .ticketr.yaml
-+
-+# View available fields with verbose output
-+ticketr schema -v
-+
-+# The command will output field mappings like:
-+# field_mappings:
-+# "Story Points":
-+# id: "customfield_10010"
-+# type: "number"
-+# "Sprint": "customfield_10020"
-+# "Epic Link": "customfield_10014"
-+```
-+
-+This is especially useful when working with custom fields that vary between JIRA instances.
-+
- ### Docker Usage
-
- Build and run using Docker:
-
-
-
-? github.com/karolswdev/ticktr/internal/core/domain [no test files]
-? github.com/karolswdev/ticktr/internal/core/ports [no test files]
-=== RUN TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks
---- PASS: TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks (0.00s)
-=== RUN TestForcePartialUploadLogic
-=== RUN TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error
-=== RUN TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error
---- PASS: TestForcePartialUploadLogic (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error (0.00s)
-=== RUN TestVerboseFlagOutput
---- PASS: TestVerboseFlagOutput (0.00s)
-=== RUN TestCli_ReadsConfigAndDefaults
---- PASS: TestCli_ReadsConfigAndDefaults (0.00s)
-=== RUN TestSchemaCmd_GeneratesValidYaml
---- PASS: TestSchemaCmd_GeneratesValidYaml (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/cmd/ticketr 0.002s
-=== RUN TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount
---- PASS: TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount (0.00s)
-=== RUN TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields
---- PASS: TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields (0.00s)
-=== RUN TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs
---- PASS: TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs (0.00s)
-=== RUN TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories
---- PASS: TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/adapters/filesystem 0.002s
-=== RUN TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully
---- PASS: TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully (0.40s)
-=== RUN TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID
- jira_adapter_test.go:61: Failed to create story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID (0.31s)
-=== RUN TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds
- jira_adapter_test.go:97: Failed to create initial story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds (0.30s)
-FAIL
-FAIL github.com/karolswdev/ticktr/internal/adapters/jira 1.010s
-=== RUN TestTicketService_CalculateFinalFields
---- PASS: TestTicketService_CalculateFinalFields (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/core/services 0.001s
-=== RUN TestParser_RecognizesTicketBlock
---- PASS: TestParser_RecognizesTicketBlock (0.00s)
-=== RUN TestParser_ParsesNestedTasks
---- PASS: TestParser_ParsesNestedTasks (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/parser 0.001s
-FAIL
-
-
-
-=== RUN TestSchemaCmd_GeneratesValidYaml
---- PASS: TestSchemaCmd_GeneratesValidYaml (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/cmd/ticketr (cached)
-
-
-
-e179e5ee3ba8722f3ecc1aab8b465ec7a4db35f3
-
-
-
-diff --git a/README.md b/README.md
-index 0337b79..fefe1a3 100644
---- a/README.md
-+++ b/README.md
-@@ -150,6 +150,24 @@ ticketr schema -v
-
- This is especially useful when working with custom fields that vary between JIRA instances.
-
-+### State Management
-+
-+Ticketr automatically tracks changes to prevent redundant updates to JIRA:
-+
-+```bash
-+# The .ticketr.state file is created automatically
-+# It stores SHA256 hashes of ticket content
-+
-+# Only changed tickets are pushed to JIRA
-+ticketr push stories.md # Skips unchanged tickets
-+
-+# The state file contains:
-+# - Ticket ID to content hash mappings
-+# - Automatically updated after each successful push
-+```
-+
-+**Note**: The `.ticketr.state` file should be added to `.gitignore` as it's environment-specific.
-+
- ### Docker Usage
-
- Build and run using Docker:
-
-
-
-? github.com/karolswdev/ticktr/internal/core/domain [no test files]
-? github.com/karolswdev/ticktr/internal/core/ports [no test files]
-? github.com/karolswdev/ticktr/internal/state [no test files]
-=== RUN TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks
---- PASS: TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks (0.00s)
-=== RUN TestForcePartialUploadLogic
-=== RUN TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error
-=== RUN TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error
---- PASS: TestForcePartialUploadLogic (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error (0.00s)
-=== RUN TestVerboseFlagOutput
---- PASS: TestVerboseFlagOutput (0.00s)
-=== RUN TestCli_ReadsConfigAndDefaults
---- PASS: TestCli_ReadsConfigAndDefaults (0.00s)
-=== RUN TestSchemaCmd_GeneratesValidYaml
---- PASS: TestSchemaCmd_GeneratesValidYaml (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/cmd/ticketr 0.003s
-=== RUN TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount
---- PASS: TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount (0.00s)
-=== RUN TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields
---- PASS: TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields (0.00s)
-=== RUN TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs
---- PASS: TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs (0.00s)
-=== RUN TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories
---- PASS: TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/adapters/filesystem 0.002s
-=== RUN TestJiraAdapter_CreateTicket_DynamicPayload
---- PASS: TestJiraAdapter_CreateTicket_DynamicPayload (0.00s)
-=== RUN TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully
---- PASS: TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully (0.38s)
-=== RUN TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID
- jira_adapter_test.go:61: Failed to create story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID (0.40s)
-=== RUN TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds
- jira_adapter_test.go:97: Failed to create initial story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds (0.30s)
-FAIL
-FAIL github.com/karolswdev/ticktr/internal/adapters/jira 1.081s
-=== RUN TestPushService_SkipsUnchangedTickets
-2025/08/27 18:36:15 Skipping unchanged ticket 'Test Ticket 1' (TICKET-1)
-2025/08/27 18:36:15 Updated ticket 'Test Ticket 2' with Jira ID: TICKET-2
---- PASS: TestPushService_SkipsUnchangedTickets (0.00s)
-=== RUN TestTicketService_CalculateFinalFields
---- PASS: TestTicketService_CalculateFinalFields (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/core/services 0.002s
-=== RUN TestParser_RecognizesTicketBlock
---- PASS: TestParser_RecognizesTicketBlock (0.00s)
-=== RUN TestParser_ParsesNestedTasks
---- PASS: TestParser_ParsesNestedTasks (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/parser 0.002s
-FAIL
-
-
-
-=== RUN TestJiraAdapter_CreateTicket_DynamicPayload
---- PASS: TestJiraAdapter_CreateTicket_DynamicPayload (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/adapters/jira 0.002s
-
-
-
-=== RUN TestPushService_SkipsUnchangedTickets
-2025/08/27 18:32:13 Skipping unchanged ticket 'Test Ticket 1' (TICKET-1)
-2025/08/27 18:32:13 Updated ticket 'Test Ticket 2' with Jira ID: TICKET-2
---- PASS: TestPushService_SkipsUnchangedTickets (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/core/services 0.002s
-
-
-
-? github.com/karolswdev/ticktr/internal/core/domain [no test files]
-? github.com/karolswdev/ticktr/internal/core/ports [no test files]
-? github.com/karolswdev/ticktr/internal/state [no test files]
-=== RUN TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks
---- PASS: TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks (0.00s)
-=== RUN TestForcePartialUploadLogic
-=== RUN TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error
-=== RUN TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error
-=== RUN TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error
---- PASS: TestForcePartialUploadLogic (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_with_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_with_errors_-_should_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/Force_flag_without_errors_-_should_not_exit_with_error (0.00s)
- --- PASS: TestForcePartialUploadLogic/No_force_flag_without_errors_-_should_not_exit_with_error (0.00s)
-=== RUN TestVerboseFlagOutput
---- PASS: TestVerboseFlagOutput (0.00s)
-=== RUN TestCli_ReadsConfigAndDefaults
---- PASS: TestCli_ReadsConfigAndDefaults (0.00s)
-=== RUN TestSchemaCmd_GeneratesValidYaml
---- PASS: TestSchemaCmd_GeneratesValidYaml (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/cmd/ticketr 0.005s
-=== RUN TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount
---- PASS: TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount (0.00s)
-=== RUN TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields
---- PASS: TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields (0.00s)
-=== RUN TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs
---- PASS: TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs (0.00s)
-=== RUN TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories
---- PASS: TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/adapters/filesystem 0.003s
-=== RUN TestJiraAdapter_CreateTicket_DynamicPayload
---- PASS: TestJiraAdapter_CreateTicket_DynamicPayload (0.00s)
-=== RUN TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully
---- PASS: TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully (0.24s)
-=== RUN TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID
- jira_adapter_test.go:61: Failed to create story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID (0.31s)
-=== RUN TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds
- jira_adapter_test.go:97: Failed to create initial story: failed to create story with status 400: {"errorMessages":[],"errors":{"issuetype":"The issue type selected is invalid."}}
---- FAIL: TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds (0.26s)
-FAIL
-FAIL github.com/karolswdev/ticktr/internal/adapters/jira 0.805s
-=== RUN TestPushService_SkipsUnchangedTickets
-2025/08/27 18:38:25 Skipping unchanged ticket 'Test Ticket 1' (TICKET-1)
-2025/08/27 18:38:25 Updated ticket 'Test Ticket 2' with Jira ID: TICKET-2
---- PASS: TestPushService_SkipsUnchangedTickets (0.00s)
-=== RUN TestTicketService_CalculateFinalFields
---- PASS: TestTicketService_CalculateFinalFields (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/core/services 0.003s
-=== RUN TestParser_RecognizesTicketBlock
---- PASS: TestParser_RecognizesTicketBlock (0.00s)
-=== RUN TestParser_ParsesNestedTasks
---- PASS: TestParser_ParsesNestedTasks (0.00s)
-PASS
-ok github.com/karolswdev/ticktr/internal/parser 0.002s
-FAIL
-
-
-
-# .ticketr.yaml - Main configuration for Ticketr.
-defaults:
- project_key: "PROJ"
- issue_type: "Task"
-
-field_mappings:
- # Maps human-readable names from Markdown's "## Fields" to Jira API IDs.
- "Type": "issuetype"
- "Project": "project"
- "Story Points":
- id: "customfield_10010"
- type: "number" # Discovered by the 'schema' command.
- # ... other mappings
-
-sync:
- pull:
- # Allow-list of fields to fetch from Jira and render in Markdown.
- fields:
- - "Type"
- - "Status"
- - "Assignee"
- - "Sprint"
-
- # Deny-list of noisy metadata fields to never pull.
- ignored_fields:
- - "updated"
- - "created"
-
-
-
-# STORY: [EPIC] E-commerce Platform Redesign
-
-## Description
-Complete redesign of the e-commerce platform to improve user experience,
-increase conversion rates, and modernize the technology stack.
-
-## Acceptance Criteria
-- New design implemented across all pages
-- Page load time improved by 50%
-- Conversion rate increased by at least 15%
-- Mobile-first responsive design
-- Accessibility WCAG 2.1 AA compliant
-
-## Tasks
-- Conduct user research and surveys
-- Create wireframes and mockups
-- Design system and component library
-- Frontend architecture planning
-- Implement product catalog pages
-- Redesign shopping cart flow
-- Optimize checkout process
-- Payment gateway integration
-- Order management system
-- Customer account dashboard
-- Search and filtering improvements
-- Performance optimization
-- SEO improvements
-- Analytics and tracking setup
-- A/B testing framework
-- Migration plan for existing data
-- Staff training materials
-- Launch plan and rollback strategy
-
-
-
-# STORY: Quick Bug Fix
-
-## Description
-Fix the navigation menu not closing on mobile devices.
-
-## Tasks
-- Investigate the issue
-- Fix event handler
-- Test on multiple devices
-
-
-
-# STORY: Sprint 23 - Authentication & User Management
-
-## Description
-Complete the authentication system and basic user management features for the Q1 release.
-
-## Acceptance Criteria
-- All authentication endpoints secure and tested
-- User management CRUD operations complete
-- Integration tests passing with >80% coverage
-
-## Tasks
-- Database schema for users and sessions
-- Password hashing and validation service
-- JWT token generation and validation
-- Login endpoint with rate limiting
-- Logout and session management
-- User registration with email verification
-- Password reset flow
-- User profile management endpoints
-- Admin user management interface
-- Integration tests for all endpoints
-
----
-
-# STORY: Performance Optimization
-
-## Description
-As a system administrator, I need the application to handle 10,000 concurrent users
-so that we can support our expected growth.
-
-## Acceptance Criteria
-- Response time < 200ms for 95% of requests
-- System handles 10,000 concurrent connections
-- Database queries optimized with proper indexing
-
-## Tasks
-- Profile current performance bottlenecks
-- Optimize database queries and add indexes
-- Implement connection pooling
-- Add Redis caching layer
-- Configure load balancer
-- Performance testing and benchmarking
-
-
-
-package jira
-
-import (
- "bytes"
- "encoding/json"
- "io"
- "net/http"
- "strings"
- "testing"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
-)
-
-// MockRoundTripper is a mock for testing HTTP requests
-type MockRoundTripper struct {
- RoundTripFunc func(req *http.Request) (*http.Response, error)
- LastRequest *http.Request
- LastBody []byte
-}
-
-func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
- m.LastRequest = req
- if req.Body != nil {
- body, _ := io.ReadAll(req.Body)
- m.LastBody = body
- req.Body = io.NopCloser(bytes.NewReader(body))
- }
- return m.RoundTripFunc(req)
-}
-
-func TestJiraAdapter_CreateTicket_DynamicPayload(t *testing.T) {
- // Create field mappings
- fieldMappings := map[string]interface{}{
- "Story Points": map[string]interface{}{
- "id": "customfield_10010",
- "type": "number",
- },
- "Sprint": "customfield_10020",
- "Labels": "labels",
- }
-
- // Create a mock round tripper
- mockTransport := &MockRoundTripper{}
- capturedPayload := ""
-
- mockTransport.RoundTripFunc = func(req *http.Request) (*http.Response, error) {
- // Capture the request body
- if req.Body != nil {
- body, _ := io.ReadAll(req.Body)
- capturedPayload = string(body)
- }
-
- // Return a successful response
- responseBody := `{"id":"10000","key":"TEST-123","self":"https://test.atlassian.net/rest/api/2/issue/10000"}`
- return &http.Response{
- StatusCode: 201,
- Body: io.NopCloser(strings.NewReader(responseBody)),
- }, nil
- }
-
- // Create HTTP client with mock transport
- httpClient := &http.Client{
- Transport: mockTransport,
- }
-
- // Create adapter with custom field mappings
- adapter := &JiraAdapter{
- baseURL: "https://test.atlassian.net",
- email: "test@example.com",
- apiKey: "test-key",
- projectKey: "TEST",
- storyType: "Task",
- client: httpClient,
- fieldMappings: fieldMappings,
- }
-
- // Create a ticket with custom fields
- ticket := domain.Ticket{
- Title: "Test Ticket",
- Description: "Test Description",
- CustomFields: map[string]string{
- "Story Points": "5",
- "Sprint": "Sprint 23",
- "Labels": "backend, api",
- },
- }
-
- // Call CreateTicket
- key, err := adapter.CreateTicket(ticket)
- if err != nil {
- t.Fatalf("CreateTicket failed: %v", err)
- }
-
- if key != "TEST-123" {
- t.Errorf("Expected key TEST-123, got %s", key)
- }
-
- // Parse the captured payload
- var payload map[string]interface{}
- if err := json.Unmarshal([]byte(capturedPayload), &payload); err != nil {
- t.Fatalf("Failed to parse captured payload: %v", err)
- }
-
- // Check that the payload contains the correct field mappings
- fields, ok := payload["fields"].(map[string]interface{})
- if !ok {
- t.Fatal("Payload does not contain 'fields'")
- }
-
- // Verify Story Points is mapped to customfield_10010 as a number
- if storyPoints, exists := fields["customfield_10010"]; !exists {
- t.Error("customfield_10010 (Story Points) not found in payload")
- } else {
- // Check that it's a number, not a string
- if _, ok := storyPoints.(float64); !ok {
- t.Errorf("Story Points should be a number, got %T: %v", storyPoints, storyPoints)
- } else if storyPoints.(float64) != 5 {
- t.Errorf("Expected Story Points to be 5, got %v", storyPoints)
- }
- }
-
- // Verify Sprint is mapped to customfield_10020
- if sprint, exists := fields["customfield_10020"]; !exists {
- t.Error("customfield_10020 (Sprint) not found in payload")
- } else if sprint != "Sprint 23" {
- t.Errorf("Expected Sprint to be 'Sprint 23', got %v", sprint)
- }
-
- // Verify Labels are mapped correctly as an array
- if labels, exists := fields["labels"]; !exists {
- t.Error("labels not found in payload")
- } else {
- if labelArray, ok := labels.([]interface{}); !ok {
- t.Errorf("Labels should be an array, got %T", labels)
- } else if len(labelArray) != 2 {
- t.Errorf("Expected 2 labels, got %d", len(labelArray))
- }
- }
-
- // Verify standard fields
- if summary, exists := fields["summary"]; !exists || summary != "Test Ticket" {
- t.Errorf("Expected summary 'Test Ticket', got %v", summary)
- }
-
- if description, exists := fields["description"]; !exists || description != "Test Description" {
- t.Errorf("Expected description 'Test Description', got %v", description)
- }
-}
-
-
-
-package services
-
-import (
- "errors"
- "path/filepath"
- "testing"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
- "github.com/karolswdev/ticktr/internal/state"
-)
-
-// Test Case TC-303.1: TestPullService_DetectsConflictState
-func TestPullService_DetectsConflictState(t *testing.T) {
- // Arrange: Create a pull_service and a StateManager
- tmpDir := t.TempDir()
- stateFile := filepath.Join(tmpDir, "test.state")
- stateManager := state.NewStateManager(stateFile)
-
- // Pre-populate the state file with {"TICKET-1": {"local_hash": "A", "remote_hash": "B"}}
- stateManager.SetStoredState("TICKET-1", state.TicketState{
- LocalHash: "A",
- RemoteHash: "B",
- })
-
- // Prepare a local Markdown file whose TICKET-1 content hashes to "C"
- localTicket := domain.Ticket{
- JiraID: "TICKET-1",
- Title: "Local Version",
- Description: "This is the local version that hashes to C",
- }
- // Calculate actual hash for local ticket (will be different from "A")
- localHash := stateManager.CalculateHash(localTicket)
- // This simulates that local has changed from "A" to something else
-
- // Mock repository with the local ticket
- mockRepo := &MockRepositoryForPull{
- tickets: []domain.Ticket{localTicket},
- }
-
- // Mock a Jira response for TICKET-1 that hashes to "D" (different from "B")
- remoteTicket := domain.Ticket{
- JiraID: "TICKET-1",
- Title: "Remote Version",
- Description: "This is the remote version that hashes to D",
- }
-
- mockJira := &MockJiraPortForPull{
- searchResult: []domain.Ticket{remoteTicket},
- }
-
- // Create the pull service
- pullService := NewPullService(mockJira, mockRepo, stateManager)
-
- // Act: Run the pull service
- result, err := pullService.Pull("test.md", PullOptions{
- ProjectKey: "TEST",
- })
-
- // Assert: The service returns a specific ErrConflictDetected error for TICKET-1
- if err == nil {
- t.Fatal("Expected conflict error, got nil")
- }
-
- if !errors.Is(err, ErrConflictDetected) {
- t.Errorf("Expected ErrConflictDetected, got: %v", err)
- }
-
- if result == nil {
- t.Fatal("Expected result even with error")
- }
-
- if len(result.Conflicts) != 1 {
- t.Errorf("Expected 1 conflict, got %d", len(result.Conflicts))
- }
-
- if len(result.Conflicts) > 0 && result.Conflicts[0] != "TICKET-1" {
- t.Errorf("Expected conflict for TICKET-1, got %v", result.Conflicts[0])
- }
-
- // Verify that the local hash was different from stored local hash "A"
- if localHash == "A" {
- t.Error("Local ticket should have a different hash than stored 'A'")
- }
-
- // Verify that remote hash was different from stored remote hash "B"
- remoteHash := stateManager.CalculateHash(remoteTicket)
- if remoteHash == "B" {
- t.Error("Remote ticket should have a different hash than stored 'B'")
- }
-}
-
-// Mock implementations for testing
-type MockRepositoryForPull struct {
- tickets []domain.Ticket
- saveTickets []domain.Ticket
- saveError error
-}
-
-func (m *MockRepositoryForPull) GetTickets(filePath string) ([]domain.Ticket, error) {
- return m.tickets, nil
-}
-
-func (m *MockRepositoryForPull) SaveTickets(filePath string, tickets []domain.Ticket) error {
- m.saveTickets = tickets
- return m.saveError
-}
-
-type MockJiraPortForPull struct {
- searchResult []domain.Ticket
- searchError error
-}
-
-func (m *MockJiraPortForPull) Authenticate() error {
- return nil
-}
-
-func (m *MockJiraPortForPull) CreateTask(task domain.Task, parentID string) (string, error) {
- return "", nil
-}
-
-func (m *MockJiraPortForPull) UpdateTask(task domain.Task) error {
- return nil
-}
-
-func (m *MockJiraPortForPull) GetProjectIssueTypes() (map[string][]string, error) {
- return nil, nil
-}
-
-func (m *MockJiraPortForPull) GetIssueTypeFields(issueTypeName string) (map[string]interface{}, error) {
- return nil, nil
-}
-
-func (m *MockJiraPortForPull) CreateTicket(ticket domain.Ticket) (string, error) {
- return "", nil
-}
-
-func (m *MockJiraPortForPull) UpdateTicket(ticket domain.Ticket) error {
- return nil
-}
-
-func (m *MockJiraPortForPull) SearchTickets(projectKey string, jql string) ([]domain.Ticket, error) {
- return m.searchResult, m.searchError
-}
-
-
-
-package services
-
-import (
- "errors"
- "fmt"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
- "github.com/karolswdev/ticktr/internal/core/ports"
- "github.com/karolswdev/ticktr/internal/state"
-)
-
-var (
- // ErrConflictDetected is returned when a merge conflict is detected
- ErrConflictDetected = errors.New("conflict detected")
-)
-
-// PullService handles pulling tickets from JIRA and updating local files
-type PullService struct {
- jiraAdapter ports.JiraPort
- repository ports.Repository
- stateManager *state.StateManager
-}
-
-// NewPullService creates a new pull service instance
-func NewPullService(jiraAdapter ports.JiraPort, repository ports.Repository, stateManager *state.StateManager) *PullService {
- return &PullService{
- jiraAdapter: jiraAdapter,
- repository: repository,
- stateManager: stateManager,
- }
-}
-
-// PullOptions contains options for the pull operation
-type PullOptions struct {
- ProjectKey string
- JQL string
- EpicKey string
- Force bool // Force overwrite even if conflicts exist
-}
-
-// PullResult contains the results of a pull operation
-type PullResult struct {
- TicketsPulled int
- TicketsUpdated int
- TicketsSkipped int
- Conflicts []string
- Errors []error
-}
-
-// Pull fetches tickets from JIRA and updates the local file
-func (ps *PullService) Pull(filePath string, options PullOptions) (*PullResult, error) {
- result := &PullResult{}
-
- // Load current state
- if err := ps.stateManager.Load(); err != nil {
- return nil, fmt.Errorf("failed to load state: %w", err)
- }
-
- // Build JQL query
- jql := ps.buildJQL(options)
-
- // Fetch tickets from JIRA
- remoteTickets, err := ps.jiraAdapter.SearchTickets(options.ProjectKey, jql)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch tickets from JIRA: %w", err)
- }
-
- // Load local tickets
- localTickets, err := ps.repository.GetTickets(filePath)
- if err != nil && !errors.Is(err, ports.ErrFileNotFound) {
- return nil, fmt.Errorf("failed to load local tickets: %w", err)
- }
-
- // Create a map of local tickets by JiraID for easier lookup
- localTicketMap := make(map[string]*domain.Ticket)
- for i := range localTickets {
- if localTickets[i].JiraID != "" {
- localTicketMap[localTickets[i].JiraID] = &localTickets[i]
- }
- }
-
- // Process each remote ticket
- mergedTickets := []domain.Ticket{}
- for _, remoteTicket := range remoteTickets {
- remoteHash := ps.stateManager.CalculateHash(remoteTicket)
-
- // Check if ticket exists locally
- localTicket, existsLocally := localTicketMap[remoteTicket.JiraID]
-
- if !existsLocally {
- // New ticket from remote
- mergedTickets = append(mergedTickets, remoteTicket)
- ps.stateManager.UpdateHash(remoteTicket)
- result.TicketsPulled++
- } else {
- // Ticket exists both locally and remotely - check for conflicts
- localHash := ps.stateManager.CalculateHash(*localTicket)
- storedState, hasStoredState := ps.stateManager.GetStoredState(remoteTicket.JiraID)
-
- if !hasStoredState {
- // No stored state - first time seeing this ticket
- // Take remote version and update state
- mergedTickets = append(mergedTickets, remoteTicket)
- ps.stateManager.UpdateHash(remoteTicket)
- result.TicketsUpdated++
- } else {
- // We have stored state - check for conflicts
- localChanged := localHash != storedState.LocalHash
- remoteChanged := remoteHash != storedState.RemoteHash
-
- if localChanged && remoteChanged {
- // Conflict detected!
- result.Conflicts = append(result.Conflicts, remoteTicket.JiraID)
-
- if options.Force {
- // Force mode - take remote version
- mergedTickets = append(mergedTickets, remoteTicket)
- ps.stateManager.UpdateHash(remoteTicket)
- result.TicketsUpdated++
- } else {
- // Keep local version but note the conflict
- mergedTickets = append(mergedTickets, *localTicket)
- result.TicketsSkipped++
- }
- } else if remoteChanged && !localChanged {
- // Only remote changed - safe to update
- mergedTickets = append(mergedTickets, remoteTicket)
- ps.stateManager.SetStoredState(remoteTicket.JiraID, state.TicketState{
- LocalHash: remoteHash,
- RemoteHash: remoteHash,
- })
- result.TicketsUpdated++
- } else if localChanged && !remoteChanged {
- // Only local changed - keep local version
- mergedTickets = append(mergedTickets, *localTicket)
- ps.stateManager.UpdateLocalHash(*localTicket)
- result.TicketsSkipped++
- } else {
- // No changes - keep as is
- mergedTickets = append(mergedTickets, *localTicket)
- result.TicketsSkipped++
- }
- }
-
- // Remove from map to track what's left (local-only tickets)
- delete(localTicketMap, remoteTicket.JiraID)
- }
- }
-
- // Add any remaining local-only tickets
- for _, localTicket := range localTicketMap {
- mergedTickets = append(mergedTickets, *localTicket)
- }
-
- // Save merged tickets to file
- if err := ps.repository.SaveTickets(filePath, mergedTickets); err != nil {
- return nil, fmt.Errorf("failed to save tickets: %w", err)
- }
-
- // Save updated state
- if err := ps.stateManager.Save(); err != nil {
- return nil, fmt.Errorf("failed to save state: %w", err)
- }
-
- // Return specific error if conflicts were detected
- if len(result.Conflicts) > 0 && !options.Force {
- return result, fmt.Errorf("%w: tickets %v have local and remote changes", ErrConflictDetected, result.Conflicts)
- }
-
- return result, nil
-}
-
-// buildJQL constructs the JQL query from options
-func (ps *PullService) buildJQL(options PullOptions) string {
- jql := ""
-
- if options.JQL != "" {
- jql = options.JQL
- }
-
- if options.EpicKey != "" {
- if jql != "" {
- jql += " AND "
- }
- jql += fmt.Sprintf(`"Epic Link" = %s`, options.EpicKey)
- }
-
- return jql
-}
-
-
-
-package services
-
-import (
- "errors"
- "testing"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
- "github.com/karolswdev/ticktr/internal/state"
-)
-
-// Test Case TC-304.1: TestPushService_ProcessesAllAndReportsFailures
-func TestPushService_ProcessesAllAndReportsFailures(t *testing.T) {
- // Arrange: Create a Markdown file with three tickets
- ticket1 := domain.Ticket{
- Title: "Ticket 1",
- Description: "Success",
- JiraID: "TEST-1",
- }
-
- ticket2 := domain.Ticket{
- Title: "Ticket 2",
- Description: "Will fail",
- JiraID: "TEST-2",
- }
-
- ticket3 := domain.Ticket{
- Title: "Ticket 3",
- Description: "Success",
- JiraID: "TEST-3",
- }
-
- tickets := []domain.Ticket{ticket1, ticket2, ticket3}
-
- // Mock repository
- mockRepo := &MockRepositoryComprehensive{
- tickets: tickets,
- }
-
- // Mock the JiraAdapter to succeed on ticket 1 and 3, but fail on ticket 2
- mockJira := &MockJiraPortComprehensive{
- failOnTicketID: "TEST-2",
- }
-
- // Create state manager
- tmpDir := t.TempDir()
- stateFile := tmpDir + "/test.state"
- stateManager := state.NewStateManager(stateFile)
-
- // Mark all tickets as changed by not having them in state
-
- // Create push service
- pushService := NewPushService(mockRepo, mockJira, stateManager)
-
- // Act: Run the push service without the --force flag
- result, err := pushService.PushTickets("test.md", ProcessOptions{
- ForcePartialUpload: false,
- })
-
- // Assert: The ProcessResult contains 2 successes and 1 failure
- if result == nil {
- t.Fatal("Expected result even with errors")
- }
-
- if result.TicketsUpdated != 2 {
- t.Errorf("Expected 2 tickets updated, got %d", result.TicketsUpdated)
- }
-
- if len(result.Errors) != 1 {
- t.Errorf("Expected 1 error, got %d", len(result.Errors))
- }
-
- // The service itself returns an error
- if err == nil {
- t.Error("Expected error to be returned when tickets fail")
- }
-
- // The mock confirms that API calls were attempted for all three tickets
- if mockJira.UpdateCallCount != 3 {
- t.Errorf("Expected UpdateTicket to be called 3 times (for all tickets), got %d", mockJira.UpdateCallCount)
- }
-}
-
-// Mock implementations for comprehensive test
-type MockRepositoryComprehensive struct {
- tickets []domain.Ticket
-}
-
-func (m *MockRepositoryComprehensive) GetTickets(filePath string) ([]domain.Ticket, error) {
- return m.tickets, nil
-}
-
-func (m *MockRepositoryComprehensive) SaveTickets(filePath string, tickets []domain.Ticket) error {
- return nil
-}
-
-type MockJiraPortComprehensive struct {
- UpdateCallCount int
- failOnTicketID string
-}
-
-func (m *MockJiraPortComprehensive) Authenticate() error {
- return nil
-}
-
-func (m *MockJiraPortComprehensive) CreateTask(task domain.Task, parentID string) (string, error) {
- return "", nil
-}
-
-func (m *MockJiraPortComprehensive) UpdateTask(task domain.Task) error {
- return nil
-}
-
-func (m *MockJiraPortComprehensive) GetProjectIssueTypes() (map[string][]string, error) {
- return nil, nil
-}
-
-func (m *MockJiraPortComprehensive) GetIssueTypeFields(issueTypeName string) (map[string]interface{}, error) {
- return nil, nil
-}
-
-func (m *MockJiraPortComprehensive) CreateTicket(ticket domain.Ticket) (string, error) {
- return "", nil
-}
-
-func (m *MockJiraPortComprehensive) UpdateTicket(ticket domain.Ticket) error {
- m.UpdateCallCount++
- if ticket.JiraID == m.failOnTicketID {
- return errors.New("simulated failure for TEST-2")
- }
- return nil
-}
-
-func (m *MockJiraPortComprehensive) SearchTickets(projectKey string, jql string) ([]domain.Ticket, error) {
- return nil, nil
-}
-
-
-
-package validation
-
-import (
- "testing"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
-)
-
-// Test Case TC-201.1: TestValidation_Hierarchical
-func TestValidation_Hierarchical(t *testing.T) {
- // Arrange: Create a ticket structure that violates a hierarchical rule
- // (e.g., a "Task" cannot be the parent of a "Story")
- tickets := []domain.Ticket{
- {
- Title: "My Task",
- Description: "This is a task",
- CustomFields: map[string]string{
- "Type": "Task",
- },
- JiraID: "PROJ-100",
- Tasks: []domain.Task{
- {
- Title: "My Story",
- Description: "This should not be allowed as a child of Task",
- CustomFields: map[string]string{
- "Type": "Story", // Story cannot be a child of Task
- },
- SourceLine: 10,
- },
- },
- },
- }
-
- // Act: Run it through a new validation service
- validator := NewValidator()
- errors := validator.ValidateHierarchy(tickets)
-
- // Assert: The service returns a specific validation error
- if len(errors) == 0 {
- t.Error("Expected validation error for invalid hierarchy, but got none")
- }
-
- // Check for the specific error
- foundError := false
- for _, err := range errors {
- if err.Message == "A 'Story' cannot be the child of a 'Task'" {
- foundError = true
- t.Logf("Successfully caught hierarchy violation: %s", err.Error())
- break
- }
- }
-
- if !foundError {
- t.Errorf("Expected specific hierarchy error message, got: %v", errors)
- }
-}
-
-// Additional test: Valid hierarchy should not produce errors
-func TestValidation_ValidHierarchy(t *testing.T) {
- tickets := []domain.Ticket{
- {
- Title: "My Story",
- CustomFields: map[string]string{
- "Type": "Story",
- },
- JiraID: "PROJ-100",
- Tasks: []domain.Task{
- {
- Title: "My Sub-task",
- CustomFields: map[string]string{
- "Type": "Sub-task", // Sub-task is allowed under Story
- },
- },
- },
- },
- }
-
- validator := NewValidator()
- errors := validator.ValidateHierarchy(tickets)
-
- if len(errors) > 0 {
- t.Errorf("Valid hierarchy produced errors: %v", errors)
- }
-}
-
-// Test Epic with various child types
-func TestValidation_EpicHierarchy(t *testing.T) {
- tickets := []domain.Ticket{
- {
- Title: "My Epic",
- CustomFields: map[string]string{
- "Type": "Epic",
- },
- JiraID: "PROJ-100",
- Tasks: []domain.Task{
- {
- Title: "Story under Epic",
- CustomFields: map[string]string{
- "Type": "Story", // Allowed
- },
- },
- {
- Title: "Task under Epic",
- CustomFields: map[string]string{
- "Type": "Task", // Allowed
- },
- },
- {
- Title: "Bug under Epic",
- CustomFields: map[string]string{
- "Type": "Bug", // Allowed
- },
- },
- {
- Title: "Sub-task under Epic",
- CustomFields: map[string]string{
- "Type": "Sub-task", // NOT allowed
- },
- SourceLine: 20,
- },
- },
- },
- }
-
- validator := NewValidator()
- errors := validator.ValidateHierarchy(tickets)
-
- if len(errors) != 1 {
- t.Errorf("Expected 1 validation error, got %d: %v", len(errors), errors)
- }
-
- if len(errors) > 0 && errors[0].Message != "A 'Sub-task' cannot be the child of a 'Epic'" {
- t.Errorf("Unexpected error message: %s", errors[0].Message)
- }
-}
-
-
-
-package validation
-
-import (
- "fmt"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
-)
-
-// ValidationError represents a validation error with context
-type ValidationError struct {
- Field string
- Message string
- Line int
-}
-
-func (v ValidationError) Error() string {
- if v.Line > 0 {
- return fmt.Sprintf("line %d: %s: %s", v.Line, v.Field, v.Message)
- }
- return fmt.Sprintf("%s: %s", v.Field, v.Message)
-}
-
-// Validator provides validation services for tickets
-type Validator struct {
- hierarchyRules map[string][]string // Maps parent type to allowed child types
-}
-
-// NewValidator creates a new validator instance
-func NewValidator() *Validator {
- return &Validator{
- hierarchyRules: map[string][]string{
- "Epic": {"Story", "Task", "Bug"},
- "Story": {"Sub-task", "Task"},
- "Task": {"Sub-task"},
- "Bug": {"Sub-task"},
- "Feature": {"Sub-task", "Task"},
- },
- }
-}
-
-// ValidateHierarchy validates ticket hierarchy rules
-func (v *Validator) ValidateHierarchy(tickets []domain.Ticket) []ValidationError {
- errors := []ValidationError{}
-
- // Build a map of ticket IDs to types
- ticketTypes := make(map[string]string)
- for _, ticket := range tickets {
- if ticket.JiraID != "" {
- if ticketType, exists := ticket.CustomFields["Type"]; exists {
- ticketTypes[ticket.JiraID] = ticketType
- }
- }
- }
-
- // Check each ticket's children
- for _, ticket := range tickets {
- parentType := ticket.CustomFields["Type"]
- if parentType == "" {
- parentType = "Story" // Default type
- }
-
- // Check if parent type has hierarchy rules
- allowedChildTypes, hasRules := v.hierarchyRules[parentType]
- if !hasRules {
- continue // No rules for this parent type
- }
-
- // Validate each child task
- for _, task := range ticket.Tasks {
- childType := task.CustomFields["Type"]
- if childType == "" {
- childType = "Sub-task" // Default child type
- }
-
- // Check if child type is allowed
- allowed := false
- for _, allowedType := range allowedChildTypes {
- if childType == allowedType {
- allowed = true
- break
- }
- }
-
- if !allowed {
- errors = append(errors, ValidationError{
- Field: fmt.Sprintf("Task '%s'", task.Title),
- Message: fmt.Sprintf("A '%s' cannot be the child of a '%s'", childType, parentType),
- Line: task.SourceLine,
- })
- }
- }
- }
-
- return errors
-}
-
-// ValidateRequiredFields validates that required fields are present
-func (v *Validator) ValidateRequiredFields(ticket domain.Ticket, requiredFields []string) []ValidationError {
- errors := []ValidationError{}
-
- // Check title
- if ticket.Title == "" {
- errors = append(errors, ValidationError{
- Field: "Title",
- Message: "Title is required",
- Line: ticket.SourceLine,
- })
- }
-
- // Check custom required fields
- for _, field := range requiredFields {
- if value, exists := ticket.CustomFields[field]; !exists || value == "" {
- errors = append(errors, ValidationError{
- Field: field,
- Message: fmt.Sprintf("Required field '%s' is missing or empty", field),
- Line: ticket.SourceLine,
- })
- }
- }
-
- return errors
-}
-
-// ValidateTickets performs comprehensive validation on tickets
-func (v *Validator) ValidateTickets(tickets []domain.Ticket) []ValidationError {
- allErrors := []ValidationError{}
-
- // Validate hierarchy
- hierarchyErrors := v.ValidateHierarchy(tickets)
- allErrors = append(allErrors, hierarchyErrors...)
-
- // Validate each ticket's required fields (basic validation)
- for _, ticket := range tickets {
- // Basic required fields check (title is always required)
- if ticket.Title == "" {
- allErrors = append(allErrors, ValidationError{
- Field: "Title",
- Message: "Title is required",
- Line: ticket.SourceLine,
- })
- }
- }
-
- return allErrors
-}
-
-
-
-package parser
-
-import (
- "testing"
-)
-
-func TestParser_RecognizesTicketBlock(t *testing.T) {
- parser := New()
-
- tickets, err := parser.Parse("../../testdata/ticket_simple.md")
- if err != nil {
- t.Fatalf("Parse failed: %v", err)
- }
-
- if len(tickets) != 1 {
- t.Fatalf("Expected 1 ticket, got %d", len(tickets))
- }
-
- ticket := tickets[0]
-
- if ticket.Title != "Create user authentication system" {
- t.Errorf("Expected title 'Create user authentication system', got '%s'", ticket.Title)
- }
-
- expectedDesc := "Implement a complete user authentication system with login, logout, and session management capabilities."
- if ticket.Description != expectedDesc {
- t.Errorf("Expected description '%s', got '%s'", expectedDesc, ticket.Description)
- }
-
- // Check CustomFields
- expectedFields := map[string]string{
- "Type": "Story",
- "Project": "PROJ",
- "Priority": "High",
- "Sprint": "10",
- }
-
- for key, expectedVal := range expectedFields {
- if val, ok := ticket.CustomFields[key]; !ok {
- t.Errorf("Missing field '%s'", key)
- } else if val != expectedVal {
- t.Errorf("Field '%s': expected '%s', got '%s'", key, expectedVal, val)
- }
- }
-
- // Check acceptance criteria
- if len(ticket.AcceptanceCriteria) != 3 {
- t.Errorf("Expected 3 acceptance criteria, got %d", len(ticket.AcceptanceCriteria))
- }
-}
-
-func TestParser_ParsesNestedTasks(t *testing.T) {
- parser := New()
-
- tickets, err := parser.Parse("../../testdata/ticket_with_tasks.md")
- if err != nil {
- t.Fatalf("Parse failed: %v", err)
- }
-
- if len(tickets) != 1 {
- t.Fatalf("Expected 1 ticket, got %d", len(tickets))
- }
-
- ticket := tickets[0]
-
- if ticket.Title != "Build payment processing system" {
- t.Errorf("Expected title 'Build payment processing system', got '%s'", ticket.Title)
- }
-
- // Check Tasks
- if len(ticket.Tasks) != 2 {
- t.Fatalf("Expected 2 tasks, got %d", len(ticket.Tasks))
- }
-
- // Check first task
- task1 := ticket.Tasks[0]
- if task1.Title != "Create payment gateway interface" {
- t.Errorf("Task 1: Expected title 'Create payment gateway interface', got '%s'", task1.Title)
- }
-
- if task1.CustomFields["Priority"] != "Low" {
- t.Errorf("Task 1: Expected Priority 'Low', got '%s'", task1.CustomFields["Priority"])
- }
-
- // Check second task
- task2 := ticket.Tasks[1]
- if task2.Title != "Implement Stripe integration" {
- t.Errorf("Task 2: Expected title 'Implement Stripe integration', got '%s'", task2.Title)
- }
-
- if task2.CustomFields["Priority"] != "Critical" {
- t.Errorf("Task 2: Expected Priority 'Critical', got '%s'", task2.CustomFields["Priority"])
- }
-
- if task2.CustomFields["Assignee"] != "John Doe" {
- t.Errorf("Task 2: Expected Assignee 'John Doe', got '%s'", task2.CustomFields["Assignee"])
- }
-}
-
-
-
-package renderer
-
-import (
- "os"
- "strings"
- "testing"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
-)
-
-// Test Case TC-206.1: TestRenderer_GeneratesCorrectMarkdown
-func TestRenderer_GeneratesCorrectMarkdown(t *testing.T) {
- // Arrange: Create a domain.Ticket object with a title, description, and custom fields
- ticket := domain.Ticket{
- Title: "Implement User Authentication",
- Description: "As a developer, I want to implement secure user authentication so that users can safely access the application.",
- CustomFields: map[string]string{
- "Priority": "High",
- "Story Points": "5",
- "Sprint": "Sprint 23",
- },
- AcceptanceCriteria: []string{
- "Users can register with email and password",
- "Passwords are securely hashed",
- "Session management is implemented",
- },
- JiraID: "PROJ-123",
- Tasks: []domain.Task{
- {
- Title: "Set up authentication database schema",
- Description: "Create necessary database tables for user authentication",
- CustomFields: map[string]string{
- "Assignee": "john.doe",
- },
- JiraID: "PROJ-124",
- },
- {
- Title: "Implement password hashing service",
- Description: "Create a service that securely hashes passwords using bcrypt",
- JiraID: "PROJ-125",
- },
- },
- }
-
- // Act: Pass the ticket to a new renderer.Render function
- renderer := NewRenderer(nil)
- result := renderer.Render(ticket)
-
- // Assert: The returned string is a well-formed # TICKET: block
- // Check for main ticket structure
- if !strings.Contains(result, "# TICKET: [PROJ-123] Implement User Authentication") {
- t.Errorf("Result does not contain expected ticket header with JIRA ID")
- }
-
- if !strings.Contains(result, "## Description") {
- t.Errorf("Result does not contain Description section")
- }
-
- if !strings.Contains(result, "As a developer, I want to implement secure user authentication") {
- t.Errorf("Result does not contain expected description text")
- }
-
- if !strings.Contains(result, "## Acceptance Criteria") {
- t.Errorf("Result does not contain Acceptance Criteria section")
- }
-
- if !strings.Contains(result, "- Users can register with email and password") {
- t.Errorf("Result does not contain expected acceptance criteria")
- }
-
- if !strings.Contains(result, "## Fields") {
- t.Errorf("Result does not contain Fields section")
- }
-
- if !strings.Contains(result, "- Priority: High") {
- t.Errorf("Result does not contain Priority field")
- }
-
- if !strings.Contains(result, "- Story Points: 5") {
- t.Errorf("Result does not contain Story Points field")
- }
-
- if !strings.Contains(result, "## Tasks") {
- t.Errorf("Result does not contain Tasks section")
- }
-
- if !strings.Contains(result, "- [PROJ-124] Set up authentication database schema") {
- t.Errorf("Result does not contain first task with JIRA ID")
- }
-
- if !strings.Contains(result, "- [PROJ-125] Implement password hashing service") {
- t.Errorf("Result does not contain second task with JIRA ID")
- }
-
- // Optional: Compare with golden file if it exists
- goldenFile := "testdata/rendered_ticket.md"
- if _, err := os.Stat(goldenFile); err == nil {
- golden, err := os.ReadFile(goldenFile)
- if err != nil {
- t.Fatalf("Failed to read golden file: %v", err)
- }
-
- expectedContent := string(golden)
- if result != expectedContent {
- t.Errorf("Rendered output does not match golden file.\nExpected:\n%s\nGot:\n%s", expectedContent, result)
- }
- } else {
- // If golden file doesn't exist, log the output for manual verification
- t.Logf("Rendered markdown:\n%s", result)
- }
-}
-
-
-
-package renderer
-
-import (
- "fmt"
- "strings"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
-)
-
-// Renderer handles conversion of tickets to Markdown format
-type Renderer struct {
- fieldMappings map[string]interface{}
-}
-
-// NewRenderer creates a new Renderer instance
-func NewRenderer(fieldMappings map[string]interface{}) *Renderer {
- if fieldMappings == nil {
- fieldMappings = getDefaultFieldMappings()
- }
- return &Renderer{
- fieldMappings: fieldMappings,
- }
-}
-
-// getDefaultFieldMappings returns default field mappings
-func getDefaultFieldMappings() map[string]interface{} {
- return map[string]interface{}{
- "Type": "issuetype",
- "Project": "project",
- "Summary": "summary",
- "Description": "description",
- "Assignee": "assignee",
- "Reporter": "reporter",
- "Priority": "priority",
- "Labels": "labels",
- "Components": "components",
- "Fix Version": "fixVersions",
- "Sprint": "customfield_10020",
- "Story Points": map[string]interface{}{
- "id": "customfield_10010",
- "type": "number",
- },
- }
-}
-
-// Render converts a domain.Ticket to Markdown format
-func (r *Renderer) Render(ticket domain.Ticket) string {
- var sb strings.Builder
-
- // Title with JIRA ID if present
- if ticket.JiraID != "" {
- sb.WriteString(fmt.Sprintf("# TICKET: [%s] %s\n", ticket.JiraID, ticket.Title))
- } else {
- sb.WriteString(fmt.Sprintf("# TICKET: %s\n", ticket.Title))
- }
- sb.WriteString("\n")
-
- // Custom fields section (excluding Type which is handled differently in some cases)
- hasCustomFields := false
- for fieldName, fieldValue := range ticket.CustomFields {
- if fieldName != "Type" && fieldName != "Parent" && fieldValue != "" {
- if !hasCustomFields {
- sb.WriteString("## Fields\n")
- hasCustomFields = true
- }
- sb.WriteString(fmt.Sprintf("- %s: %s\n", fieldName, fieldValue))
- }
- }
- if hasCustomFields {
- sb.WriteString("\n")
- }
-
- // Description section
- if ticket.Description != "" {
- sb.WriteString("## Description\n")
- sb.WriteString(ticket.Description)
- sb.WriteString("\n\n")
- }
-
- // Acceptance Criteria section
- if len(ticket.AcceptanceCriteria) > 0 {
- sb.WriteString("## Acceptance Criteria\n")
- for _, criterion := range ticket.AcceptanceCriteria {
- sb.WriteString(fmt.Sprintf("- %s\n", criterion))
- }
- sb.WriteString("\n")
- }
-
- // Tasks section
- if len(ticket.Tasks) > 0 {
- sb.WriteString("## Tasks\n")
- for _, task := range ticket.Tasks {
- if task.JiraID != "" {
- sb.WriteString(fmt.Sprintf("- [%s] %s\n", task.JiraID, task.Title))
- } else {
- sb.WriteString(fmt.Sprintf("- %s\n", task.Title))
- }
-
- // Task custom fields (indented)
- for fieldName, fieldValue := range task.CustomFields {
- if fieldValue != "" {
- sb.WriteString(fmt.Sprintf(" - %s: %s\n", fieldName, fieldValue))
- }
- }
-
- // Task description (indented)
- if task.Description != "" {
- lines := strings.Split(task.Description, "\n")
- for _, line := range lines {
- if line != "" {
- sb.WriteString(fmt.Sprintf(" %s\n", line))
- }
- }
- }
-
- // Task acceptance criteria (indented)
- if len(task.AcceptanceCriteria) > 0 {
- sb.WriteString(" ### Acceptance Criteria\n")
- for _, criterion := range task.AcceptanceCriteria {
- sb.WriteString(fmt.Sprintf(" - %s\n", criterion))
- }
- }
- }
- sb.WriteString("\n")
- }
-
- return sb.String()
-}
-
-// RenderMultiple renders multiple tickets to a single Markdown document
-func (r *Renderer) RenderMultiple(tickets []domain.Ticket) string {
- var sb strings.Builder
-
- for i, ticket := range tickets {
- sb.WriteString(r.Render(ticket))
-
- // Add separator between tickets except for the last one
- if i < len(tickets)-1 {
- sb.WriteString("---\n\n")
- }
- }
-
- return sb.String()
-}
-
-
-
-package state
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
-)
-
-func TestStateManager_BidirectionalHashTracking(t *testing.T) {
- // Create a temporary directory for test files
- tmpDir := t.TempDir()
- stateFile := filepath.Join(tmpDir, "test.state")
-
- // Create a state manager
- sm := NewStateManager(stateFile)
-
- // Create a test ticket
- ticket := domain.Ticket{
- JiraID: "TEST-123",
- Title: "Test Ticket",
- Description: "Original description",
- }
-
- // Test 1: New ticket should have no stored state
- state, exists := sm.GetStoredState(ticket.JiraID)
- if exists {
- t.Error("Expected no stored state for new ticket")
- }
-
- // Test 2: UpdateHash should set both local and remote to same hash
- sm.UpdateHash(ticket)
- state, exists = sm.GetStoredState(ticket.JiraID)
- if !exists {
- t.Fatal("Expected stored state after UpdateHash")
- }
- if state.LocalHash != state.RemoteHash {
- t.Error("Expected LocalHash and RemoteHash to be equal after UpdateHash")
- }
- if state.LocalHash == "" {
- t.Error("Expected non-empty hash")
- }
-
- // Test 3: Save and Load persistence
- err := sm.Save()
- if err != nil {
- t.Fatalf("Failed to save state: %v", err)
- }
-
- // Create new manager and load
- sm2 := NewStateManager(stateFile)
- err = sm2.Load()
- if err != nil {
- t.Fatalf("Failed to load state: %v", err)
- }
-
- state2, exists := sm2.GetStoredState(ticket.JiraID)
- if !exists {
- t.Fatal("Expected persisted state after load")
- }
- if state2.LocalHash != state.LocalHash || state2.RemoteHash != state.RemoteHash {
- t.Error("State not properly persisted")
- }
-
- // Test 4: UpdateLocalHash only updates local
- ticket.Description = "Modified locally"
- originalRemote := state.RemoteHash
- sm2.UpdateLocalHash(ticket)
- state3, _ := sm2.GetStoredState(ticket.JiraID)
- if state3.LocalHash == originalRemote {
- t.Error("Expected LocalHash to change after UpdateLocalHash")
- }
- if state3.RemoteHash != originalRemote {
- t.Error("RemoteHash should not change with UpdateLocalHash")
- }
-
- // Test 5: UpdateRemoteHash only updates remote
- newRemoteHash := "remote-hash-from-jira"
- sm2.UpdateRemoteHash(ticket.JiraID, newRemoteHash)
- state4, _ := sm2.GetStoredState(ticket.JiraID)
- if state4.RemoteHash != newRemoteHash {
- t.Error("Expected RemoteHash to be updated")
- }
- if state4.LocalHash != state3.LocalHash {
- t.Error("LocalHash should not change with UpdateRemoteHash")
- }
-}
-
-func TestStateManager_ConflictDetection(t *testing.T) {
- tmpDir := t.TempDir()
- stateFile := filepath.Join(tmpDir, "test.state")
- sm := NewStateManager(stateFile)
-
- ticket := domain.Ticket{
- JiraID: "TEST-456",
- Title: "Conflict Test",
- Description: "Original",
- }
-
- // Initially no conflict (no stored state)
- if sm.DetectConflict(ticket) {
- t.Error("Should not detect conflict for new ticket")
- }
-
- // Set initial state (synced)
- sm.UpdateHash(ticket)
- if sm.DetectConflict(ticket) {
- t.Error("Should not detect conflict when synced")
- }
-
- // Simulate remote change only
- sm.UpdateRemoteHash(ticket.JiraID, "different-remote-hash")
- if sm.DetectConflict(ticket) {
- t.Error("Should not detect conflict when only remote changed")
- }
-
- // Now change local too - this creates a conflict
- ticket.Description = "Modified locally"
- if !sm.DetectConflict(ticket) {
- t.Error("Should detect conflict when both local and remote changed")
- }
-
- // Test IsRemoteChanged
- if !sm.IsRemoteChanged(ticket.JiraID, "new-remote-hash") {
- t.Error("Should detect remote change")
- }
-
- state, _ := sm.GetStoredState(ticket.JiraID)
- if sm.IsRemoteChanged(ticket.JiraID, state.RemoteHash) {
- t.Error("Should not detect change when hash matches")
- }
-}
-
-func TestStateManager_BackwardCompatibility(t *testing.T) {
- // Test that old state files with simple string hashes can still be loaded
- tmpDir := t.TempDir()
- stateFile := filepath.Join(tmpDir, "old.state")
-
- // Create an old-format state file
- oldState := `{"TEST-789": "simple-hash-string"}`
- err := os.WriteFile(stateFile, []byte(oldState), 0644)
- if err != nil {
- t.Fatalf("Failed to write old state file: %v", err)
- }
-
- // Try to load with new manager - should handle gracefully
- sm := NewStateManager(stateFile)
- err = sm.Load()
- // We expect this to fail with the new structure, which is acceptable
- // as we're making a breaking change in v2.0
- if err == nil {
- t.Error("Expected error when loading old format, as v2.0 is a breaking change")
- }
-}
-
-
-
-# TICKET: Task with Details
-## Description
-Story description here.
-
-## Fields
-Type: Story
-Project: PROJ
-
-## Tasks
-- Implement feature
- ## Description
- This is a detailed description of the task that needs to be implemented
-
- ## Acceptance Criteria
- - The feature should work correctly
- - All tests should pass
-
-
-
-# TICKET: Create user authentication system
-
-## Description
-Implement a complete user authentication system with login, logout, and session management capabilities.
-
-## Fields
-Type: Story
-Project: PROJ
-Priority: High
-Sprint: 10
-
-## Acceptance Criteria
-- Users can register with email and password
-- Users can login with valid credentials
-- Sessions persist across browser restarts
-
-
-
-# TICKET: Build payment processing system
-
-## Description
-Create a complete payment processing system that handles multiple payment methods.
-
-## Fields
-Type: Story
-Project: PROJ
-Priority: High
-Sprint: 10
-
-## Acceptance Criteria
-- System supports credit card payments
-- System supports PayPal integration
-- All transactions are logged
-
-## Tasks
-- Create payment gateway interface
- ## Description
- Define the interface for all payment gateways
-
- ## Fields
- Priority: Low
-
- ## Acceptance Criteria
- - Interface defined in code
- - Documentation complete
-
-- Implement Stripe integration
- ## Description
- Integrate Stripe as the primary payment processor
-
- ## Fields
- Priority: Critical
- Assignee: John Doe
-
- ## Acceptance Criteria
- - Stripe SDK integrated
- - Test transactions working
-
-
-
-# TICKET: User Authentication
-## Description
-As a user, I want to be able to log in to the system.
-
-## Fields
-Type: Story
-Project: PROJ
-
-## Acceptance Criteria
-- User can enter username and password
-- System validates credentials
-
-## Tasks
-- Implement login form
-- Add validation logic
-
-# TICKET: User Profile Management
-
-## Description
-As a user, I want to manage my profile information.
-
-## Fields
-Type: Story
-Project: PROJ
-
-## Acceptance Criteria
-- User can view profile
-- User can edit profile
-
-## Tasks
-- Create profile page
-- Add edit functionality
-
-
-
-# Git files
-.git
-.gitignore
-
-# Environment files
-.env
-.env.local
-.env.*.local
-
-# Build artifacts
-jira-story-creator
-*.exe
-*.dll
-*.so
-*.dylib
-
-# Test files
-*_test.go
-*.test
-
-# Documentation
-*.md
-docs/
-
-# IDE files
-.vscode/
-.idea/
-*.swp
-*.swo
-*~
-
-# OS files
-.DS_Store
-Thumbs.db
-
-# Temporary files
-*.tmp
-*.temp
-/tmp
-
-
-
-# Jira Configuration
-# Copy this file to .env and fill in your actual values
-
-# Your Jira instance URL (without trailing slash)
-JIRA_URL=https://your-domain.atlassian.net
-
-# Email address associated with your Jira account
-JIRA_EMAIL=your.email@company.com
-
-# Jira API token (generate from Account Settings → Security → API tokens)
-JIRA_API_KEY=your-api-token-here
-
-# Default Jira project key for creating stories
-JIRA_PROJECT_KEY=PROJ
-
-
-
-#!/bin/bash
-
-# Check available issue types in JIRA project
-echo "Checking issue types for project: $JIRA_PROJECT_KEY"
-echo ""
-
-# Get project details including issue types
-curl -s \
- -u "$JIRA_EMAIL:$JIRA_API_KEY" \
- -H "Accept: application/json" \
- "$JIRA_URL/rest/api/2/project/$JIRA_PROJECT_KEY" | \
- python3 -m json.tool | \
- grep -A2 '"issueTypes"' | \
- grep '"name"' | \
- cut -d'"' -f4
-
-echo ""
-echo "If you need more details, run:"
-echo "curl -u \"\$JIRA_EMAIL:\$JIRA_API_KEY\" -H \"Accept: application/json\" \"\$JIRA_URL/rest/api/2/project/\$JIRA_PROJECT_KEY\" | python3 -m json.tool"
-
-
-
-MIT License
-
-Copyright (c) 2024 Karol Swdev
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-
-
-package main
-
-import (
- "os"
- "testing"
- "io/ioutil"
- "strings"
- "github.com/spf13/viper"
-)
-
-// Test Case TC-4.1: CLI_WithForceFlag_OnPartialError_UploadsValidTasks
-func TestCLI_WithForceFlag_OnPartialError_UploadsValidTasks(t *testing.T) {
- // This is more of an integration test that would test the CLI behavior
- // Since we need actual Jira connection to properly test this,
- // we'll create a simpler unit test for the force-partial-upload logic
-
- // Create a test Markdown file with mixed valid/invalid content
- testContent := `# STORY: Test Story for Force Flag
-
-## Description
-This story tests the force flag functionality.
-
-## Tasks
-- Valid task that should be processed
-- Another valid task
-
----
-
-# STORY: [INVALID-999999] Story with invalid ID
-
-## Description
-This story has an invalid Jira ID that will fail update.
-
-## Tasks
-- Task that should fail
-`
-
- // Create temporary test file
- tmpFile, err := ioutil.TempFile("", "test_force_*.md")
- if err != nil {
- t.Fatalf("Failed to create temp file: %v", err)
- }
- defer os.Remove(tmpFile.Name())
-
- if _, err := tmpFile.WriteString(testContent); err != nil {
- t.Fatalf("Failed to write test content: %v", err)
- }
- tmpFile.Close()
-
- // Test that with force flag, the exit code is 0 even with errors
- // This would normally be tested by running the actual CLI command
- // For unit testing, we verify the logic is in place
-
- // The actual behavior is implemented in main.go:
- // if len(result.Errors) > 0 && !forcePartialUpload {
- // os.Exit(2)
- // }
-
- // This means with force flag true and errors, it should NOT exit with code 2
- forcePartialUpload := true
- hasErrors := true
-
- shouldExitWithError := hasErrors && !forcePartialUpload
-
- if forcePartialUpload && shouldExitWithError {
- t.Error("With force flag enabled, should not exit with error code even when errors occur")
- }
-
- // Verify the opposite case
- forcePartialUpload = false
- shouldExitWithError = hasErrors && !forcePartialUpload
-
- if !shouldExitWithError {
- t.Error("Without force flag, should exit with error code when errors occur")
- }
-}
-
-// TestForcePartialUploadLogic verifies the force partial upload behavior
-func TestForcePartialUploadLogic(t *testing.T) {
- testCases := []struct {
- name string
- forceFlag bool
- hasErrors bool
- expectedExitError bool
- }{
- {
- name: "Force flag with errors - should not exit with error",
- forceFlag: true,
- hasErrors: true,
- expectedExitError: false,
- },
- {
- name: "No force flag with errors - should exit with error",
- forceFlag: false,
- hasErrors: true,
- expectedExitError: true,
- },
- {
- name: "Force flag without errors - should not exit with error",
- forceFlag: true,
- hasErrors: false,
- expectedExitError: false,
- },
- {
- name: "No force flag without errors - should not exit with error",
- forceFlag: false,
- hasErrors: false,
- expectedExitError: false,
- },
- }
-
- for _, tc := range testCases {
- t.Run(tc.name, func(t *testing.T) {
- // This simulates the logic in main.go
- shouldExitWithError := tc.hasErrors && !tc.forceFlag
-
- if shouldExitWithError != tc.expectedExitError {
- t.Errorf("Expected exit error: %v, got: %v", tc.expectedExitError, shouldExitWithError)
- }
- })
- }
-}
-
-// TestVerboseFlagOutput tests that verbose flag enables detailed logging
-func TestVerboseFlagOutput(t *testing.T) {
- // This test verifies that the verbose flag configuration is properly handled
- // In practice, this would test actual log output
-
- verboseFlag := true
-
- // When verbose is true, we expect detailed log flags
- if verboseFlag {
- // In main.go, this sets: log.SetFlags(log.Ltime | log.Lshortfile | log.Lmicroseconds)
- // We can't easily test the actual log output in a unit test,
- // but we verify the logic is correct
- expectedLogDetail := "detailed"
- actualLogDetail := "detailed" // This would be set based on verbose flag
-
- if !strings.Contains(actualLogDetail, expectedLogDetail) {
- t.Error("Verbose flag should enable detailed logging")
- }
- }
-}
-
-// TestCli_ReadsConfigAndDefaults tests that the CLI reads configuration from .ticketr.yaml
-func TestCli_ReadsConfigAndDefaults(t *testing.T) {
- // Create a temporary config file
- configContent := `defaults:
- project_key: "CONF"
- issue_type: "Task"
-
-field_mappings:
- Type: "issuetype"
- Project: "project"
-`
-
- // Write config to a temp file
- tmpFile, err := os.CreateTemp("", ".ticketr.*.yaml")
- if err != nil {
- t.Fatalf("Failed to create temp file: %v", err)
- }
- defer os.Remove(tmpFile.Name())
-
- if _, err := tmpFile.WriteString(configContent); err != nil {
- t.Fatalf("Failed to write config: %v", err)
- }
- tmpFile.Close()
-
- // Initialize viper with the test config
- testViper := viper.New()
- testViper.SetConfigFile(tmpFile.Name())
-
- if err := testViper.ReadInConfig(); err != nil {
- t.Fatalf("Failed to read config: %v", err)
- }
-
- // Assert: The loaded configuration has the correct values
- projectKey := testViper.GetString("defaults.project_key")
- if projectKey != "CONF" {
- t.Errorf("Expected project_key to be 'CONF', got '%s'", projectKey)
- }
-
- issueType := testViper.GetString("defaults.issue_type")
- if issueType != "Task" {
- t.Errorf("Expected issue_type to be 'Task', got '%s'", issueType)
- }
-
- typeMapping := testViper.GetString("field_mappings.Type")
- if typeMapping != "issuetype" {
- t.Errorf("Expected Type mapping to be 'issuetype', got '%s'", typeMapping)
- }
-}
-
-
-
-package services
-
-import (
- "fmt"
- "log"
-
- "github.com/karolswdev/ticktr/internal/core/ports"
- "github.com/karolswdev/ticktr/internal/state"
-)
-
-// PushService handles pushing tickets to JIRA with state management
-type PushService struct {
- repository ports.Repository
- jiraClient ports.JiraPort
- stateManager *state.StateManager
-}
-
-// NewPushService creates a new instance of PushService
-func NewPushService(repository ports.Repository, jiraClient ports.JiraPort, stateManager *state.StateManager) *PushService {
- return &PushService{
- repository: repository,
- jiraClient: jiraClient,
- stateManager: stateManager,
- }
-}
-
-// PushTickets processes tickets with state management to avoid redundant updates
-func (s *PushService) PushTickets(filePath string, options ProcessOptions) (*ProcessResult, error) {
- result := &ProcessResult{
- Errors: []string{},
- }
-
- // Load the current state
- if err := s.stateManager.Load(); err != nil {
- log.Printf("Warning: Could not load state file: %v", err)
- // Continue anyway - we'll treat everything as changed
- }
-
- // Read tickets from the file
- tickets, err := s.repository.GetTickets(filePath)
- if err != nil {
- return nil, fmt.Errorf("failed to read tickets from file: %w", err)
- }
-
- // Process each ticket
- for i := range tickets {
- ticket := &tickets[i]
-
- // Check if ticket has changed
- if !s.stateManager.HasChanged(*ticket) {
- log.Printf("Skipping unchanged ticket '%s' (%s)", ticket.Title, ticket.JiraID)
- continue
- }
-
- // Check if ticket needs to be created or updated
- if ticket.JiraID != "" {
- // Update existing ticket in Jira
- err := s.jiraClient.UpdateTicket(*ticket)
- if err != nil {
- errMsg := fmt.Sprintf("Failed to update ticket '%s' (%s): %v", ticket.Title, ticket.JiraID, err)
- result.Errors = append(result.Errors, errMsg)
- log.Println(errMsg)
- continue
- }
- result.TicketsUpdated++
- s.stateManager.UpdateHash(*ticket)
- log.Printf("Updated ticket '%s' with Jira ID: %s\n", ticket.Title, ticket.JiraID)
- } else {
- // Create new ticket in Jira
- jiraID, err := s.jiraClient.CreateTicket(*ticket)
- if err != nil {
- errMsg := fmt.Sprintf("Failed to create ticket '%s': %v", ticket.Title, err)
- result.Errors = append(result.Errors, errMsg)
- log.Println(errMsg)
- continue
- }
-
- // Update the ticket with the new Jira ID
- ticket.JiraID = jiraID
- result.TicketsCreated++
- s.stateManager.UpdateHash(*ticket)
- log.Printf("Created ticket '%s' with Jira ID: %s\n", ticket.Title, jiraID)
- }
-
- // Process tasks for this ticket
- for j := range ticket.Tasks {
- task := &ticket.Tasks[j]
-
- // Tasks don't have separate state tracking for now
- // They're included in the parent ticket's hash
-
- // Check if task needs to be created or updated
- if task.JiraID != "" {
- // Update existing task in Jira
- err := s.jiraClient.UpdateTask(*task)
- if err != nil {
- errMsg := fmt.Sprintf(" Failed to update task '%s' (%s): %v", task.Title, task.JiraID, err)
- result.Errors = append(result.Errors, errMsg)
- log.Println(errMsg)
- continue
- }
- result.TasksUpdated++
- log.Printf(" Updated task '%s' with Jira ID: %s\n", task.Title, task.JiraID)
- } else {
- // Create new task in Jira (needs parent ticket to exist)
- if ticket.JiraID == "" {
- errMsg := fmt.Sprintf(" Cannot create task '%s' - parent ticket has no Jira ID", task.Title)
- result.Errors = append(result.Errors, errMsg)
- log.Println(errMsg)
- continue
- }
-
- taskJiraID, err := s.jiraClient.CreateTask(*task, ticket.JiraID)
- if err != nil {
- errMsg := fmt.Sprintf(" Failed to create task '%s': %v", task.Title, err)
- result.Errors = append(result.Errors, errMsg)
- log.Println(errMsg)
- continue
- }
-
- // Update the task with the new Jira ID
- task.JiraID = taskJiraID
- result.TasksCreated++
- log.Printf(" Created task '%s' with Jira ID: %s\n", task.Title, taskJiraID)
- }
- }
-
- // Update the hash after processing tasks too
- s.stateManager.UpdateHash(*ticket)
- }
-
- // Save the updated tickets back to the file
- err = s.repository.SaveTickets(filePath, tickets)
- if err != nil {
- // This is not critical - we've already created the items in Jira
- log.Printf("Warning: Failed to save updated tickets back to file: %v\n", err)
- }
-
- // Save the state file
- if err := s.stateManager.Save(); err != nil {
- log.Printf("Warning: Could not save state file: %v", err)
- }
-
- // Return error if any tickets failed
- if len(result.Errors) > 0 {
- return result, fmt.Errorf("%d ticket(s) failed to process", len(result.Errors))
- }
-
- return result, nil
-}
-
-
-
-package services
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
-)
-
-// Test Case TC-301.1: TestTicketService_RejectsLegacyStoryFormat
-func TestTicketService_RejectsLegacyStoryFormat(t *testing.T) {
- // Arrange: Create a Markdown file containing the old # STORY: format
- tmpDir := t.TempDir()
- testFile := filepath.Join(tmpDir, "legacy_story.md")
-
- legacyContent := `# STORY: Old Format Story
-
-## Description
-This uses the old format
-
-## Acceptance Criteria
-- Should be rejected
-
-## Tasks
-- Old task format`
-
- err := os.WriteFile(testFile, []byte(legacyContent), 0644)
- if err != nil {
- t.Fatalf("Failed to create test file: %v", err)
- }
-
- // Create mock repository that returns error for legacy format
- mockRepo := &MockLegacyRepository{}
- mockJira := &MockJiraPortForLegacy{}
-
- // Act: Pass this file to the ticket_service
- service := NewTicketService(mockRepo, mockJira)
- result, err := service.ProcessTicketsWithOptions(testFile, ProcessOptions{})
-
- // Assert: The service returns an error and the ProcessResult indicates zero tickets were processed
- if err == nil {
- t.Error("Expected error for legacy STORY format, but got nil")
- }
-
- if result != nil && result.TicketsCreated > 0 {
- t.Errorf("Expected zero tickets processed, but got %d created", result.TicketsCreated)
- }
-
- if mockJira.createCalled {
- t.Error("JIRA adapter should not be called for legacy format")
- }
-}
-
-// MockLegacyRepository rejects legacy format
-type MockLegacyRepository struct{}
-
-func (m *MockLegacyRepository) GetTickets(filepath string) ([]domain.Ticket, error) {
- // Read the file to check format
- content, err := os.ReadFile(filepath)
- if err != nil {
- return nil, err
- }
-
- // Check for legacy STORY format
- contentStr := string(content)
- if len(contentStr) >= 8 && contentStr[:8] == "# STORY:" {
- return nil, fmt.Errorf("legacy STORY format is no longer supported, use # TICKET: instead")
- }
-
- return []domain.Ticket{}, nil
-}
-
-func (m *MockLegacyRepository) SaveTickets(filepath string, tickets []domain.Ticket) error {
- return nil
-}
-
-// MockJiraPortForLegacy tracks if methods were called
-type MockJiraPortForLegacy struct {
- createCalled bool
- updateCalled bool
-}
-
-func (m *MockJiraPortForLegacy) Authenticate() error {
- return nil
-}
-
-func (m *MockJiraPortForLegacy) CreateTask(task domain.Task, parentID string) (string, error) {
- return "TASK-123", nil
-}
-
-func (m *MockJiraPortForLegacy) UpdateTask(task domain.Task) error {
- return nil
-}
-
-func (m *MockJiraPortForLegacy) GetProjectIssueTypes() (map[string][]string, error) {
- return nil, nil
-}
-
-func (m *MockJiraPortForLegacy) GetIssueTypeFields(issueTypeName string) (map[string]interface{}, error) {
- return nil, nil
-}
-
-func (m *MockJiraPortForLegacy) CreateTicket(ticket domain.Ticket) (string, error) {
- m.createCalled = true
- return "TICKET-123", nil
-}
-
-func (m *MockJiraPortForLegacy) UpdateTicket(ticket domain.Ticket) error {
- m.updateCalled = true
- return nil
-}
-
-func (m *MockJiraPortForLegacy) SearchTickets(projectKey string, jql string) ([]domain.Ticket, error) {
- return []domain.Ticket{}, nil
-}
-
-// Original test
-func TestTicketService_CalculateFinalFields(t *testing.T) {
- service := NewTicketService(nil, nil)
-
- parent := domain.Ticket{
- CustomFields: map[string]string{
- "Priority": "High",
- "Sprint": "10",
- },
- }
-
- task := domain.Task{
- CustomFields: map[string]string{
- "Priority": "Low",
- },
- }
-
- result := service.calculateFinalFields(parent, task)
-
- // Assert: Priority should be overridden to "Low", Sprint should be inherited as "10"
- if result["Priority"] != "Low" {
- t.Errorf("Expected Priority to be 'Low', got '%s'", result["Priority"])
- }
-
- if result["Sprint"] != "10" {
- t.Errorf("Expected Sprint to be '10', got '%s'", result["Sprint"])
- }
-}
-
-
-
-package services
-
-import (
- "fmt"
- "log"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
- "github.com/karolswdev/ticktr/internal/core/ports"
-)
-
-// TicketService orchestrates the business logic for processing tickets
-type TicketService struct {
- repository ports.Repository
- jiraClient ports.JiraPort
-}
-
-// NewTicketService creates a new instance of TicketService
-func NewTicketService(repository ports.Repository, jiraClient ports.JiraPort) *TicketService {
- return &TicketService{
- repository: repository,
- jiraClient: jiraClient,
- }
-}
-
-// ProcessResult holds the results of processing tickets and tasks
-type ProcessResult struct {
- TicketsCreated int
- TicketsUpdated int
- TasksCreated int
- TasksUpdated int
- Errors []string
-}
-
-// ProcessOptions contains options for processing tickets
-type ProcessOptions struct {
- ForcePartialUpload bool
-}
-
-// calculateFinalFields merges parent fields with task fields (task fields override parent fields)
-func (s *TicketService) calculateFinalFields(parent domain.Ticket, task domain.Task) map[string]string {
- // Start with parent's fields
- finalFields := make(map[string]string)
- for k, v := range parent.CustomFields {
- finalFields[k] = v
- }
-
- // Override with task's fields
- for k, v := range task.CustomFields {
- finalFields[k] = v
- }
-
- return finalFields
-}
-
-
-// ProcessTickets reads tickets from the repository and creates/updates them in Jira
-func (s *TicketService) ProcessTickets(filePath string) (*ProcessResult, error) {
- return s.ProcessTicketsWithOptions(filePath, ProcessOptions{})
-}
-
-// ProcessTicketsWithOptions reads tickets from the repository and creates/updates them in Jira with options
-func (s *TicketService) ProcessTicketsWithOptions(filePath string, options ProcessOptions) (*ProcessResult, error) {
- result := &ProcessResult{
- Errors: []string{},
- }
-
- // Read tickets from the file
- tickets, err := s.repository.GetTickets(filePath)
- if err != nil {
- return nil, fmt.Errorf("failed to read tickets from file: %w", err)
- }
-
- // Process each ticket
- for i := range tickets {
- ticket := &tickets[i]
-
- // Check if ticket needs to be created or updated
- if ticket.JiraID != "" {
- // Update existing ticket in Jira
- err := s.jiraClient.UpdateTicket(*ticket)
- if err != nil {
- errMsg := fmt.Sprintf("Failed to update ticket '%s' (%s): %v", ticket.Title, ticket.JiraID, err)
- result.Errors = append(result.Errors, errMsg)
- log.Println(errMsg)
- continue
- }
- result.TicketsUpdated++
- log.Printf("Updated ticket '%s' with Jira ID: %s\n", ticket.Title, ticket.JiraID)
- } else {
- // Create new ticket in Jira
- jiraID, err := s.jiraClient.CreateTicket(*ticket)
- if err != nil {
- errMsg := fmt.Sprintf("Failed to create ticket '%s': %v", ticket.Title, err)
- result.Errors = append(result.Errors, errMsg)
- log.Println(errMsg)
- continue
- }
-
- // Update the ticket with the new Jira ID
- ticket.JiraID = jiraID
- result.TicketsCreated++
- log.Printf("Created ticket '%s' with Jira ID: %s\n", ticket.Title, jiraID)
- }
-
- // Process tasks for this ticket
- for j := range ticket.Tasks {
- task := &ticket.Tasks[j]
-
- // Check if task needs to be created or updated
- if task.JiraID != "" {
- // Update existing task in Jira
- err := s.jiraClient.UpdateTask(*task)
- if err != nil {
- errMsg := fmt.Sprintf(" Failed to update task '%s' (%s): %v", task.Title, task.JiraID, err)
- result.Errors = append(result.Errors, errMsg)
- log.Println(errMsg)
- continue
- }
- result.TasksUpdated++
- log.Printf(" Updated task '%s' with Jira ID: %s\n", task.Title, task.JiraID)
- } else {
- // Create new task in Jira (needs parent ticket to exist)
- if ticket.JiraID == "" {
- errMsg := fmt.Sprintf(" Cannot create task '%s' - parent ticket has no Jira ID", task.Title)
- result.Errors = append(result.Errors, errMsg)
- log.Println(errMsg)
- continue
- }
-
- taskJiraID, err := s.jiraClient.CreateTask(*task, ticket.JiraID)
- if err != nil {
- errMsg := fmt.Sprintf(" Failed to create task '%s': %v", task.Title, err)
- result.Errors = append(result.Errors, errMsg)
- log.Println(errMsg)
- continue
- }
-
- // Update the task with the new Jira ID
- task.JiraID = taskJiraID
- result.TasksCreated++
- log.Printf(" Created task '%s' with Jira ID: %s\n", task.Title, taskJiraID)
- }
- }
- }
-
- // Save the updated tickets back to the file
- err = s.repository.SaveTickets(filePath, tickets)
- if err != nil {
- // This is not critical - we've already created the items in Jira
- log.Printf("Warning: Failed to save updated tickets back to file: %v\n", err)
- }
-
- return result, nil
-}
-
-
-
-package parser
-
-import (
- "bufio"
- "fmt"
- "os"
- "regexp"
- "strings"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
-)
-
-type Parser struct{}
-
-func New() *Parser {
- return &Parser{}
-}
-
-func (p *Parser) Parse(filePath string) ([]domain.Ticket, error) {
- file, err := os.Open(filePath)
- if err != nil {
- return nil, fmt.Errorf("failed to open file: %w", err)
- }
- defer file.Close()
-
- scanner := bufio.NewScanner(file)
- var lines []string
- lineNum := 0
- for scanner.Scan() {
- lineNum++
- lines = append(lines, scanner.Text())
- }
-
- if err := scanner.Err(); err != nil {
- return nil, fmt.Errorf("error reading file: %w", err)
- }
-
- return p.parseLines(lines)
-}
-
-func (p *Parser) parseLines(lines []string) ([]domain.Ticket, error) {
- var tickets []domain.Ticket
- ticketRegex := regexp.MustCompile(`^# TICKET:\s*(?:\[([^\]]+)\])?\s*(.+)$`)
-
- for i := 0; i < len(lines); i++ {
- matches := ticketRegex.FindStringSubmatch(lines[i])
- if matches != nil {
- ticket := domain.Ticket{
- JiraID: matches[1],
- Title: strings.TrimSpace(matches[2]),
- SourceLine: i + 1,
- CustomFields: make(map[string]string),
- }
-
- // Parse ticket sections
- i++
- nextIdx := p.parseTicketSections(&ticket, lines, i, 0)
-
- tickets = append(tickets, ticket)
-
- // Continue from where parseTicketSections left off
- // But subtract 1 because the loop will increment
- i = nextIdx - 1
- }
- }
-
- return tickets, nil
-}
-
-func (p *Parser) parseTicketSections(ticket *domain.Ticket, lines []string, startIdx int, indent int) int {
- i := startIdx
- indentStr := strings.Repeat(" ", indent)
-
- for i < len(lines) {
- line := lines[i]
-
- // Check if we've reached the next ticket
- if strings.HasPrefix(strings.TrimSpace(line), "# TICKET:") {
- return i
- }
-
- // Check if we've gone back to a lower indent level
- if indent > 0 && !strings.HasPrefix(line, indentStr) && strings.TrimSpace(line) != "" {
- break
- }
-
- // Remove the expected indentation
- if indent > 0 && strings.HasPrefix(line, indentStr) {
- line = line[indent:]
- }
-
- // Check for section headers
- if strings.HasPrefix(line, "## Description") {
- i++
- desc := p.parseMultilineSection(lines, i, indent)
- ticket.Description = strings.TrimSpace(desc.content)
- i = desc.nextIdx
- } else if strings.HasPrefix(line, "## Fields") {
- i++
- fields := p.parseFieldsSection(lines, i, indent)
- for k, v := range fields.fields {
- ticket.CustomFields[k] = v
- }
- i = fields.nextIdx
- } else if strings.HasPrefix(line, "## Acceptance Criteria") {
- i++
- ac := p.parseAcceptanceCriteria(lines, i, indent)
- ticket.AcceptanceCriteria = ac.criteria
- i = ac.nextIdx
- } else if strings.HasPrefix(line, "## Tasks") {
- i++
- tasks := p.parseTasks(lines, i, indent)
- ticket.Tasks = tasks.tasks
- i = tasks.nextIdx
- // If parseTasks found a next ticket, we should return that index
- if i < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i]), "# TICKET:") {
- return i
- }
- } else {
- i++
- }
- }
-
- return i
-}
-
-type multilineResult struct {
- content string
- nextIdx int
-}
-
-func (p *Parser) parseMultilineSection(lines []string, startIdx int, baseIndent int) multilineResult {
- var content []string
- i := startIdx
-
- for i < len(lines) {
- line := lines[i]
-
- // Check if line starts a new section (## header)
- trimmed := strings.TrimSpace(line)
- if strings.HasPrefix(trimmed, "##") || strings.HasPrefix(trimmed, "# TICKET:") {
- break
- }
-
- // Check for task list item (starts with -)
- if baseIndent > 0 && strings.TrimSpace(line) != "" && strings.HasPrefix(strings.TrimSpace(line), "-") && !strings.HasPrefix(line, strings.Repeat(" ", baseIndent+2)) {
- break
- }
-
- // Add the line content (removing base indentation if present)
- if baseIndent > 0 && strings.HasPrefix(line, strings.Repeat(" ", baseIndent)) {
- content = append(content, line[baseIndent:])
- } else if baseIndent == 0 {
- content = append(content, line)
- } else if strings.TrimSpace(line) == "" {
- content = append(content, "")
- } else {
- break
- }
-
- i++
- }
-
- return multilineResult{
- content: strings.TrimSpace(strings.Join(content, "\n")),
- nextIdx: i,
- }
-}
-
-type fieldsResult struct {
- fields map[string]string
- nextIdx int
-}
-
-func (p *Parser) parseFieldsSection(lines []string, startIdx int, baseIndent int) fieldsResult {
- fields := make(map[string]string)
- i := startIdx
- fieldRegex := regexp.MustCompile(`^([^:]+):\s*(.*)$`)
-
- for i < len(lines) {
- line := lines[i]
-
- // Remove base indentation
- if baseIndent > 0 && strings.HasPrefix(line, strings.Repeat(" ", baseIndent)) {
- line = line[baseIndent:]
- }
-
- trimmed := strings.TrimSpace(line)
-
- // Stop at next section or end
- if strings.HasPrefix(trimmed, "##") || strings.HasPrefix(trimmed, "# TICKET:") {
- break
- }
-
- // Stop at task list item if we're in a task context
- if baseIndent > 0 && trimmed != "" && strings.HasPrefix(trimmed, "-") {
- break
- }
-
- // Skip comments and empty lines
- if strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "##") {
- i++
- continue
- }
-
- if trimmed == "" {
- i++
- continue
- }
-
- // Parse field
- if matches := fieldRegex.FindStringSubmatch(trimmed); matches != nil {
- fields[matches[1]] = strings.TrimSpace(matches[2])
- }
-
- i++
- }
-
- return fieldsResult{
- fields: fields,
- nextIdx: i,
- }
-}
-
-type criteriaResult struct {
- criteria []string
- nextIdx int
-}
-
-func (p *Parser) parseAcceptanceCriteria(lines []string, startIdx int, baseIndent int) criteriaResult {
- var criteria []string
- i := startIdx
-
- for i < len(lines) {
- if i >= len(lines) {
- break
- }
- originalLine := lines[i]
- line := originalLine
-
- // Check if line has less indentation than expected (indicates we're back at parent level)
- expectedIndent := strings.Repeat(" ", baseIndent)
- if baseIndent > 0 && !strings.HasPrefix(line, expectedIndent) && strings.TrimSpace(line) != "" {
- // Line has content but doesn't have the required indentation - we've left this section
- break
- }
-
- // Remove base indentation
- if baseIndent > 0 && strings.HasPrefix(line, expectedIndent) {
- line = line[baseIndent:]
- }
-
- trimmed := strings.TrimSpace(line)
-
- // Stop at next section
- if strings.HasPrefix(trimmed, "##") || strings.HasPrefix(trimmed, "# TICKET:") {
- break
- }
-
- // Stop at task list item if we're in the parent context and see a dash without further indentation
- // Don't break for acceptance criteria items that are properly indented within their section
- // The AC items within a task should have their - at the start after removing task indent
- // This is only for stopping when we see a new TASK at the parent level
-
- // Parse criteria item
- if strings.HasPrefix(trimmed, "-") {
- criterion := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
- if criterion != "" {
- criteria = append(criteria, criterion)
- }
- }
-
- i++
- }
-
- return criteriaResult{
- criteria: criteria,
- nextIdx: i,
- }
-}
-
-type tasksResult struct {
- tasks []domain.Task
- nextIdx int
-}
-
-func (p *Parser) parseTasks(lines []string, startIdx int, baseIndent int) tasksResult {
- var tasks []domain.Task
- i := startIdx
- taskRegex := regexp.MustCompile(`^-\s*(?:\[([^\]]+)\])?\s*(.+)$`)
-
- for i < len(lines) {
- line := lines[i]
-
- // Remove base indentation
- if baseIndent > 0 && strings.HasPrefix(line, strings.Repeat(" ", baseIndent)) {
- line = line[baseIndent:]
- }
-
- trimmed := strings.TrimSpace(line)
-
- // Stop at next ticket-level section
- if strings.HasPrefix(trimmed, "## ") && !strings.HasPrefix(line, " ") {
- break
- }
- if strings.HasPrefix(trimmed, "# TICKET:") {
- break
- }
-
- // Check for task item
- if matches := taskRegex.FindStringSubmatch(trimmed); matches != nil {
- task := domain.Task{
- JiraID: matches[1],
- Title: strings.TrimSpace(matches[2]),
- SourceLine: i + 1,
- CustomFields: make(map[string]string),
- }
-
- // Parse task sections (they should be indented)
- i++
- i = p.parseTaskSections(&task, lines, i, baseIndent+2)
-
- tasks = append(tasks, task)
- i-- // Adjust because loop will increment
- }
-
- i++
- }
-
- return tasksResult{
- tasks: tasks,
- nextIdx: i,
- }
-}
-
-func (p *Parser) parseTaskSections(task *domain.Task, lines []string, startIdx int, indent int) int {
- i := startIdx
- indentStr := strings.Repeat(" ", indent)
-
- for i < len(lines) {
- if i >= len(lines) {
- break
- }
-
- line := lines[i]
-
- // Check if we've reached a new ticket
- trimmed := strings.TrimSpace(line)
- if strings.HasPrefix(trimmed, "# TICKET:") {
- break
- }
-
- // If line doesn't start with expected indent and is not empty, we're done with this task
- if !strings.HasPrefix(line, indentStr) && trimmed != "" {
- // Check if it's a task-level item (starts with -)
- if strings.HasPrefix(trimmed, "-") {
- break
- }
- // Check if it's a ticket-level section
- if strings.HasPrefix(trimmed, "##") && !strings.HasPrefix(line, " ") {
- break
- }
- }
-
- // Process the line with proper indentation removed
- processLine := line
- if strings.HasPrefix(line, indentStr) {
- processLine = line[indent:]
- }
-
- trimmed = strings.TrimSpace(processLine)
-
- // Parse different sections
- if strings.HasPrefix(trimmed, "## Description") {
- i++
- desc := p.parseMultilineSection(lines, i, indent)
- task.Description = strings.TrimSpace(desc.content)
- i = desc.nextIdx
- } else if strings.HasPrefix(trimmed, "## Fields") {
- i++
- fields := p.parseFieldsSection(lines, i, indent)
- for k, v := range fields.fields {
- task.CustomFields[k] = v
- }
- i = fields.nextIdx
- } else if strings.HasPrefix(trimmed, "## Acceptance Criteria") {
- i++
- ac := p.parseAcceptanceCriteria(lines, i, indent)
- task.AcceptanceCriteria = ac.criteria
- i = ac.nextIdx
- } else {
- i++
- }
- }
-
- return i
-}
-
-
-
-package state
-
-import (
- "crypto/sha256"
- "encoding/json"
- "fmt"
- "io"
- "os"
- "path/filepath"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
-)
-
-// TicketState represents the state of a ticket with bidirectional hashes
-type TicketState struct {
- LocalHash string `json:"local_hash"`
- RemoteHash string `json:"remote_hash"`
-}
-
-// StateManager manages the state file for tracking ticket changes
-type StateManager struct {
- stateFilePath string
- state map[string]TicketState // Maps ticket ID to bidirectional state
-}
-
-// NewStateManager creates a new state manager instance
-func NewStateManager(stateFilePath string) *StateManager {
- if stateFilePath == "" {
- stateFilePath = ".ticketr.state"
- }
-
- return &StateManager{
- stateFilePath: stateFilePath,
- state: make(map[string]TicketState),
- }
-}
-
-// Load reads the state file from disk
-func (sm *StateManager) Load() error {
- // If state file doesn't exist, that's okay - we start with empty state
- if _, err := os.Stat(sm.stateFilePath); os.IsNotExist(err) {
- return nil
- }
-
- file, err := os.Open(sm.stateFilePath)
- if err != nil {
- return fmt.Errorf("failed to open state file: %w", err)
- }
- defer file.Close()
-
- decoder := json.NewDecoder(file)
- if err := decoder.Decode(&sm.state); err != nil {
- return fmt.Errorf("failed to decode state file: %w", err)
- }
-
- return nil
-}
-
-// Save writes the current state to disk
-func (sm *StateManager) Save() error {
- // Create directory if it doesn't exist
- dir := filepath.Dir(sm.stateFilePath)
- if err := os.MkdirAll(dir, 0755); err != nil {
- return fmt.Errorf("failed to create state directory: %w", err)
- }
-
- file, err := os.Create(sm.stateFilePath)
- if err != nil {
- return fmt.Errorf("failed to create state file: %w", err)
- }
- defer file.Close()
-
- encoder := json.NewEncoder(file)
- encoder.SetIndent("", " ")
- if err := encoder.Encode(sm.state); err != nil {
- return fmt.Errorf("failed to encode state: %w", err)
- }
-
- return nil
-}
-
-// CalculateHash computes the SHA256 hash of a ticket's content
-func (sm *StateManager) CalculateHash(ticket domain.Ticket) string {
- h := sha256.New()
-
- // Include all relevant fields in the hash
- io.WriteString(h, ticket.Title)
- io.WriteString(h, ticket.Description)
-
- // Include acceptance criteria
- for _, ac := range ticket.AcceptanceCriteria {
- io.WriteString(h, ac)
- }
-
- // Include custom fields in a deterministic order
- for k, v := range ticket.CustomFields {
- io.WriteString(h, k)
- io.WriteString(h, v)
- }
-
- // Include tasks
- for _, task := range ticket.Tasks {
- io.WriteString(h, task.Title)
- io.WriteString(h, task.Description)
- for _, ac := range task.AcceptanceCriteria {
- io.WriteString(h, ac)
- }
- for k, v := range task.CustomFields {
- io.WriteString(h, k)
- io.WriteString(h, v)
- }
- }
-
- return fmt.Sprintf("%x", h.Sum(nil))
-}
-
-// HasChanged checks if a ticket has changed since last push
-func (sm *StateManager) HasChanged(ticket domain.Ticket) bool {
- if ticket.JiraID == "" {
- // New tickets always need to be pushed
- return true
- }
-
- currentHash := sm.CalculateHash(ticket)
- storedState, exists := sm.state[ticket.JiraID]
-
- // If we don't have a stored state, consider it changed
- if !exists {
- return true
- }
-
- return currentHash != storedState.LocalHash
-}
-
-// UpdateHash updates the stored hash for a ticket (updates both local and remote)
-func (sm *StateManager) UpdateHash(ticket domain.Ticket) {
- if ticket.JiraID != "" {
- hash := sm.CalculateHash(ticket)
- sm.state[ticket.JiraID] = TicketState{
- LocalHash: hash,
- RemoteHash: hash,
- }
- }
-}
-
-// UpdateLocalHash updates only the local hash for a ticket
-func (sm *StateManager) UpdateLocalHash(ticket domain.Ticket) {
- if ticket.JiraID != "" {
- state := sm.state[ticket.JiraID]
- state.LocalHash = sm.CalculateHash(ticket)
- sm.state[ticket.JiraID] = state
- }
-}
-
-// UpdateRemoteHash updates only the remote hash for a ticket
-func (sm *StateManager) UpdateRemoteHash(ticketID string, hash string) {
- state := sm.state[ticketID]
- state.RemoteHash = hash
- sm.state[ticketID] = state
-}
-
-// GetStoredState returns the stored state for a ticket ID
-func (sm *StateManager) GetStoredState(ticketID string) (TicketState, bool) {
- state, exists := sm.state[ticketID]
- return state, exists
-}
-
-// SetStoredState sets the state for a ticket ID (useful for testing)
-func (sm *StateManager) SetStoredState(ticketID string, state TicketState) {
- sm.state[ticketID] = state
-}
-
-// DetectConflict checks if there's a conflict (both local and remote changed)
-func (sm *StateManager) DetectConflict(ticket domain.Ticket) bool {
- if ticket.JiraID == "" {
- return false
- }
-
- currentHash := sm.CalculateHash(ticket)
- storedState, exists := sm.state[ticket.JiraID]
-
- if !exists {
- return false
- }
-
- // Conflict occurs when both local and remote have changed
- localChanged := currentHash != storedState.LocalHash
- remoteChanged := storedState.RemoteHash != storedState.LocalHash
-
- return localChanged && remoteChanged
-}
-
-// IsRemoteChanged checks if only the remote has changed
-func (sm *StateManager) IsRemoteChanged(ticketID string, remoteHash string) bool {
- storedState, exists := sm.state[ticketID]
- if !exists {
- return true // Consider new remote content as changed
- }
- return remoteHash != storedState.RemoteHash
-}
-
-
-
-version: '3.8'
-
-services:
- ticketr:
- build:
- context: .
- dockerfile: Dockerfile
- image: ticketr:latest
- environment:
- - JIRA_URL=${JIRA_URL}
- - JIRA_EMAIL=${JIRA_EMAIL}
- - JIRA_API_KEY=${JIRA_API_KEY}
- - JIRA_PROJECT_KEY=${JIRA_PROJECT_KEY}
- volumes:
- - ./stories:/data
- - ./.ticketr.yaml:/data/.ticketr.yaml:ro
- working_dir: /data
- command: ["push", "/data/stories.md"]
-
-
-
-# Multi-stage build for ticktr Jira Story Creator
-# Stage 1: Build the Go binary
-FROM golang:1.22-alpine AS builder
-
-# Install git for go mod dependencies
-RUN apk add --no-cache git
-
-# Set working directory
-WORKDIR /build
-
-# Copy go mod file
-COPY go.mod ./
-
-# Download dependencies (go.sum will be created if not present)
-RUN go mod download || true
-
-# Copy source code
-COPY . .
-
-# Build the binary with optimization flags
-RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
- -ldflags="-w -s" \
- -o ticketr \
- cmd/ticketr/main.go
-
-# Stage 2: Create minimal runtime image
-FROM alpine:3.18
-
-# Install ca-certificates for HTTPS connections
-RUN apk --no-cache add ca-certificates
-
-# Create non-root user for security
-RUN addgroup -g 1000 -S appuser && \
- adduser -u 1000 -S appuser -G appuser
-
-# Set working directory
-WORKDIR /app
-
-# Copy binary from builder stage
-COPY --from=builder /build/ticketr .
-
-# Change ownership to non-root user
-RUN chown -R appuser:appuser /app
-
-# Switch to non-root user
-USER appuser
-
-# Set entrypoint
-ENTRYPOINT ["./ticketr"]
-
-# Default command (can be overridden)
-CMD ["--help"]
-
-
-
-module github.com/karolswdev/ticktr
-
-go 1.22.2
-
-require (
- github.com/fsnotify/fsnotify v1.7.0 // indirect
- github.com/hashicorp/hcl v1.0.0 // indirect
- github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/magiconair/properties v1.8.7 // indirect
- github.com/mitchellh/mapstructure v1.5.0 // indirect
- github.com/pelletier/go-toml/v2 v2.1.0 // indirect
- github.com/sagikazarmark/locafero v0.4.0 // indirect
- github.com/sagikazarmark/slog-shim v0.1.0 // indirect
- github.com/sourcegraph/conc v0.3.0 // indirect
- github.com/spf13/afero v1.11.0 // indirect
- github.com/spf13/cast v1.6.0 // indirect
- github.com/spf13/cobra v1.8.0 // indirect
- github.com/spf13/pflag v1.0.5 // indirect
- github.com/spf13/viper v1.18.2 // indirect
- github.com/subosito/gotenv v1.6.0 // indirect
- go.uber.org/atomic v1.9.0 // indirect
- go.uber.org/multierr v1.9.0 // indirect
- golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
- golang.org/x/sys v0.15.0 // indirect
- golang.org/x/text v0.14.0 // indirect
- gopkg.in/ini.v1 v1.67.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
-)
-
-
-
-package filesystem
-
-import (
- "os"
- "testing"
-)
-
-// Test Case TC-1.1: Parser_ParseInput_ValidFile_ReturnsCorrectStoryCount
-func TestParser_ParseInput_ValidFile_ReturnsCorrectStoryCount(t *testing.T) {
- // Arrange: Create a string representing a valid Markdown input with two distinct stories
- markdownContent := `# TICKET: User Authentication
-## Description
-As a user, I want to be able to log in to the system.
-
-## Fields
-Type: Story
-Project: PROJ
-
-## Acceptance Criteria
-- User can enter username and password
-- System validates credentials
-
-## Tasks
-- Implement login form
-- Add validation logic
-
-# TICKET: User Profile Management
-
-## Description
-As a user, I want to manage my profile information.
-
-## Fields
-Type: Story
-Project: PROJ
-
-## Acceptance Criteria
-- User can view profile
-- User can edit profile
-
-## Tasks
-- Create profile page
-- Add edit functionality
-`
-
- // Create a temporary file
- tmpFile, err := os.CreateTemp("", "test_stories_*.md")
- if err != nil {
- t.Fatalf("Failed to create temp file: %v", err)
- }
- defer os.Remove(tmpFile.Name())
-
- // Write content to temp file
- if _, err := tmpFile.WriteString(markdownContent); err != nil {
- t.Fatalf("Failed to write to temp file: %v", err)
- }
- tmpFile.Close()
-
- // Act: Pass the file to the parser
- repo := NewFileRepository()
- tickets, err := repo.GetTickets(tmpFile.Name())
-
- // Assert: The parser returns a slice containing exactly two Ticket objects
- if err != nil {
- t.Fatalf("Expected no error, got: %v", err)
- }
- if len(tickets) != 2 {
- t.Errorf("Expected 2 tickets, got %d", len(tickets))
- }
-}
-
-// Test Case TC-1.2: Parser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields
-func TestParser_ParseInput_TaskWithDetails_CorrectlyPopulatesTaskFields(t *testing.T) {
- // Arrange: Create a Markdown string for a single story with one task that has nested Description and Acceptance Criteria
- markdownContent := `# TICKET: Task with Details
-## Description
-Story description here.
-
-## Fields
-Type: Story
-Project: PROJ
-
-## Tasks
-- Implement feature
- ## Description
- This is a detailed description of the task that needs to be implemented
-
- ## Acceptance Criteria
- - The feature should work correctly
- - All tests should pass
-`
-
- // Create a temporary file
- tmpFile, err := os.CreateTemp("", "test_task_details_*.md")
- if err != nil {
- t.Fatalf("Failed to create temp file: %v", err)
- }
- defer os.Remove(tmpFile.Name())
-
- // Write content to temp file
- if _, err := tmpFile.WriteString(markdownContent); err != nil {
- t.Fatalf("Failed to write to temp file: %v", err)
- }
- tmpFile.Close()
-
- // Act: Parse the string
- repo := NewFileRepository()
- tickets, err := repo.GetTickets(tmpFile.Name())
-
- // Assert: The resulting Task object has its Description and AcceptanceCriteria fields correctly populated
- if err != nil {
- t.Fatalf("Expected no error, got: %v", err)
- }
- if len(tickets) != 1 {
- t.Fatalf("Expected 1 ticket, got %d", len(tickets))
- }
- if len(tickets[0].Tasks) != 1 {
- t.Fatalf("Expected 1 task, got %d", len(tickets[0].Tasks))
- }
-
- task := tickets[0].Tasks[0]
- if task.Description != "This is a detailed description of the task that needs to be implemented" {
- t.Errorf("Expected task description to be populated, got: %q", task.Description)
- }
- if len(task.AcceptanceCriteria) != 2 {
- t.Errorf("Expected 2 acceptance criteria, got %d", len(task.AcceptanceCriteria))
- }
- if len(task.AcceptanceCriteria) > 0 && task.AcceptanceCriteria[0] != "The feature should work correctly" {
- t.Errorf("First AC incorrect: %q", task.AcceptanceCriteria[0])
- }
-}
-
-// Test Case TC-1.3: Parser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs
-func TestParser_ParseInput_WithAndWithoutJiraKeys_CorrectlyPopulatesIDs(t *testing.T) {
- // Arrange: Create a Markdown string with stories and tasks with and without Jira keys
- markdownContent := `# TICKET: [PROJ-123] Story with Jira Key
-## Description
-This story has a Jira key.
-
-## Fields
-Type: Story
-Project: PROJ
-
-## Tasks
-- [PROJ-124] Task with Jira key
-- Task without Jira key
-
-# TICKET: Story without Jira Key
-## Description
-This story has no Jira key.
-
-## Fields
-Type: Story
-Project: PROJ
-
-## Tasks
-- Another task without key
-`
-
- // Create a temporary file
- tmpFile, err := os.CreateTemp("", "test_jira_keys_*.md")
- if err != nil {
- t.Fatalf("Failed to create temp file: %v", err)
- }
- defer os.Remove(tmpFile.Name())
-
- // Write content to temp file
- if _, err := tmpFile.WriteString(markdownContent); err != nil {
- t.Fatalf("Failed to write to temp file: %v", err)
- }
- tmpFile.Close()
-
- // Act: Parse the string
- repo := NewFileRepository()
- tickets, err := repo.GetTickets(tmpFile.Name())
-
- // Assert: The JiraID field is correctly populated for items with keys and empty for those without
- if err != nil {
- t.Fatalf("Expected no error, got: %v", err)
- }
- if len(tickets) != 2 {
- t.Fatalf("Expected 2 tickets, got %d", len(tickets))
- }
-
- // Check first ticket (has Jira key)
- if tickets[0].JiraID != "PROJ-123" {
- t.Errorf("Expected first ticket JiraID to be 'PROJ-123', got: %q", tickets[0].JiraID)
- }
- if tickets[0].Title != "Story with Jira Key" {
- t.Errorf("Expected first ticket title to be 'Story with Jira Key', got: %q", tickets[0].Title)
- }
-
- // Check first ticket's tasks
- if len(tickets[0].Tasks) != 2 {
- t.Fatalf("Expected 2 tasks in first ticket, got %d", len(tickets[0].Tasks))
- }
- if tickets[0].Tasks[0].JiraID != "PROJ-124" {
- t.Errorf("Expected first task JiraID to be 'PROJ-124', got: %q", tickets[0].Tasks[0].JiraID)
- }
- if tickets[0].Tasks[1].JiraID != "" {
- t.Errorf("Expected second task JiraID to be empty, got: %q", tickets[0].Tasks[1].JiraID)
- }
-
- // Check second ticket (no Jira key)
- if tickets[1].JiraID != "" {
- t.Errorf("Expected second ticket JiraID to be empty, got: %q", tickets[1].JiraID)
- }
- if tickets[1].Title != "Story without Jira Key" {
- t.Errorf("Expected second ticket title to be 'Story without Jira Key', got: %q", tickets[1].Title)
- }
-}
-
-// Test Case TC-1.4: Parser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories
-func TestParser_ParseInput_MalformedStoryHeading_ReturnsErrorAndNoStories(t *testing.T) {
- // Arrange: Create a Markdown string where a story heading is malformed
- markdownContent := `## STORY: This is malformed (should be # not ##)
-## Description
-This should fail parsing.
-
-## Tasks
-- Some task
-`
-
- // Create a temporary file
- tmpFile, err := os.CreateTemp("", "test_malformed_*.md")
- if err != nil {
- t.Fatalf("Failed to create temp file: %v", err)
- }
- defer os.Remove(tmpFile.Name())
-
- // Write content to temp file
- if _, err := tmpFile.WriteString(markdownContent); err != nil {
- t.Fatalf("Failed to write to temp file: %v", err)
- }
- tmpFile.Close()
-
- // Act: Parse the string
- repo := NewFileRepository()
- tickets, err := repo.GetTickets(tmpFile.Name())
-
- // Assert: The parser returns no error but an empty slice (no valid tickets found)
- if err != nil {
- t.Errorf("Expected no error, got: %v", err)
- }
- if len(tickets) != 0 {
- t.Errorf("Expected 0 tickets for malformed input, got %d", len(tickets))
- }
-}
-
-
-
-package ports
-
-import (
- "errors"
- "github.com/karolswdev/ticktr/internal/core/domain"
-)
-
-var (
- // ErrFileNotFound is returned when a file doesn't exist
- ErrFileNotFound = errors.New("file not found")
-)
-
-// Repository defines the interface for ticket persistence operations
-type Repository interface {
- // GetTickets reads and parses tickets from a file
- GetTickets(filepath string) ([]domain.Ticket, error)
- // SaveTickets writes tickets to a file in the custom Markdown format
- SaveTickets(filepath string, tickets []domain.Ticket) error
-}
-
-
-
-package services
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
- "github.com/karolswdev/ticktr/internal/state"
-)
-
-// MockRepository is a mock implementation of the Repository interface
-type MockRepository struct {
- tickets []domain.Ticket
- savedTickets []domain.Ticket
-}
-
-func (m *MockRepository) GetTickets(filepath string) ([]domain.Ticket, error) {
- return m.tickets, nil
-}
-
-func (m *MockRepository) SaveTickets(filepath string, tickets []domain.Ticket) error {
- m.savedTickets = tickets
- return nil
-}
-
-// MockJiraPort is a mock implementation of the JiraPort interface
-type MockJiraPort struct {
- UpdateTicketCalled int
- CreateTicketCalled int
- UpdateTaskCalled int
- CreateTaskCalled int
-}
-
-func (m *MockJiraPort) Authenticate() error {
- return nil
-}
-
-func (m *MockJiraPort) CreateTask(task domain.Task, parentID string) (string, error) {
- m.CreateTaskCalled++
- return "MOCK-TASK-123", nil
-}
-
-func (m *MockJiraPort) UpdateTask(task domain.Task) error {
- m.UpdateTaskCalled++
- return nil
-}
-
-func (m *MockJiraPort) GetProjectIssueTypes() (map[string][]string, error) {
- return nil, nil
-}
-
-func (m *MockJiraPort) GetIssueTypeFields(issueTypeName string) (map[string]interface{}, error) {
- return nil, nil
-}
-
-func (m *MockJiraPort) CreateTicket(ticket domain.Ticket) (string, error) {
- m.CreateTicketCalled++
- return fmt.Sprintf("MOCK-%d", m.CreateTicketCalled), nil
-}
-
-func (m *MockJiraPort) UpdateTicket(ticket domain.Ticket) error {
- m.UpdateTicketCalled++
- return nil
-}
-
-func (m *MockJiraPort) SearchTickets(projectKey string, jql string) ([]domain.Ticket, error) {
- return []domain.Ticket{}, nil
-}
-
-func TestPushService_SkipsUnchangedTickets(t *testing.T) {
- // Create a temporary state file
- tmpDir := t.TempDir()
- stateFile := filepath.Join(tmpDir, ".ticketr.state")
-
- // Create state manager
- stateManager := state.NewStateManager(stateFile)
-
- // Create test ticket
- ticket1 := domain.Ticket{
- Title: "Test Ticket 1",
- Description: "Test Description 1",
- JiraID: "TICKET-1",
- CustomFields: map[string]string{
- "Priority": "High",
- },
- }
-
- // Pre-populate the state file with the same hash
- stateManager.SetStoredState("TICKET-1", state.TicketState{
- LocalHash: stateManager.CalculateHash(ticket1),
- RemoteHash: stateManager.CalculateHash(ticket1),
- })
- if err := stateManager.Save(); err != nil {
- t.Fatalf("Failed to save initial state: %v", err)
- }
-
- // Create a second ticket that has changed
- ticket2 := domain.Ticket{
- Title: "Test Ticket 2",
- Description: "Test Description 2 - Updated",
- JiraID: "TICKET-2",
- CustomFields: map[string]string{
- "Priority": "Low",
- },
- }
-
- // Pre-populate with a different hash (simulating changed content)
- stateManager.SetStoredState("TICKET-2", state.TicketState{
- LocalHash: "different-hash",
- RemoteHash: "different-hash",
- })
- if err := stateManager.Save(); err != nil {
- t.Fatalf("Failed to save initial state: %v", err)
- }
-
- // Create mock repository with both tickets
- mockRepo := &MockRepository{
- tickets: []domain.Ticket{ticket1, ticket2},
- }
-
- // Create mock Jira client
- mockJira := &MockJiraPort{}
-
- // Create push service
- pushService := NewPushService(mockRepo, mockJira, stateManager)
-
- // Run push
- result, err := pushService.PushTickets("test.md", ProcessOptions{})
- if err != nil {
- t.Fatalf("PushTickets failed: %v", err)
- }
-
- // Verify that UpdateTicket was only called once (for ticket2)
- if mockJira.UpdateTicketCalled != 1 {
- t.Errorf("Expected UpdateTicket to be called 1 time, got %d", mockJira.UpdateTicketCalled)
- }
-
- // Verify the result
- if result.TicketsUpdated != 1 {
- t.Errorf("Expected 1 ticket updated, got %d", result.TicketsUpdated)
- }
-
- // Verify the state file was updated
- newStateManager := state.NewStateManager(stateFile)
- if err := newStateManager.Load(); err != nil {
- t.Fatalf("Failed to load updated state: %v", err)
- }
-
- // Check that ticket2's hash was updated
- storedState, exists := newStateManager.GetStoredState("TICKET-2")
- if !exists {
- t.Error("TICKET-2 state not found in state")
- }
- expectedHash := newStateManager.CalculateHash(ticket2)
- if storedState.LocalHash != expectedHash || storedState.RemoteHash != expectedHash {
- t.Errorf("TICKET-2 state not updated correctly. Got local=%s remote=%s, expected %s", storedState.LocalHash, storedState.RemoteHash, expectedHash)
- }
-
- // Clean up
- os.Remove(stateFile)
-}
-
-
-
-# Ticketr - Software Requirements Specification
-
-**Version:** 2.0
-**Status:** Modernization Baseline
-
-## Introduction
-
-This document outlines the software requirements for **Ticketr v2.0**. It serves as the single source of truth for what the system must do, the constraints under which it must operate, and the rules governing its development and deployment.
-
-Each requirement has a **unique, stable ID** (e.g., `PROD-001`). These IDs **MUST** be used to link implementation stories and test cases back to these foundational requirements, ensuring complete traceability.
-
-The requirement keywords (`MUST`, `MUST NOT`, `SHOULD`, `SHOULD NOT`, `MAY`) are used as defined in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).
-
----
-
-## 1. Product & Functional Requirements
-
-*Defines what the system does; its core features and capabilities.*
-
-| ID | Title | Description | Rationale |
-| :--- | :--- | :--- | :--- |
-| **PROD-001** | **Core:** File-Based State | The system **MUST** operate on an input Markdown file and produce a new Markdown file as its primary output. This output file **MUST** be a valid input file for subsequent runs, with Jira Issue Keys injected for all successfully processed items. Files **MUST** use the `# TICKET:` schema format. | This creates a self-contained, idempotent workflow that supports easy recovery from failures and iterative updates. The file itself becomes the record of the system's state. |
-| **PROD-002** | Hierarchical Validation | The system **MUST** validate input hierarchically. If a ticket's definition is malformed, the ticket and all its child tasks **MUST** be rejected as a single unit. | To prevent the creation of orphaned tasks or incomplete tickets in Jira, ensuring data integrity. |
-| **PROD-003** | Rich Create & Update Logic | The system **MUST** support both "create" and "update" operations for tickets and tasks, triggered by the presence of a Jira Issue Key (e.g., `[PROJ-456]`). When updating a ticket, the system **MUST** update its fields in Jira and **MUST** also process its child tasks (creating or updating them as required). | To provide a comprehensive, "single source of truth" workflow where the Markdown file's state can be fully synchronized with Jira. |
-| **PROD-004** | Human-Readable Logging | The system **MUST** write a summary of its execution to a local, human-readable flat text log file, in addition to the console. | To provide a persistent and easily reviewable record of the application's execution for auditing and debugging purposes. |
-| **PROD-005** | Rich Task Definitions | The system **MUST** be able to parse and sync a `Description` and `Acceptance Criteria` for individual tasks, in addition to their titles. Tasks **MUST** support custom `## Fields` sections for field overrides. | To allow for the creation of detailed, well-defined tasks that do not require immediate follow-up in the Jira UI. |
-| **PROD-009** | Hierarchical Field Inheritance | The system **MUST** implement field inheritance where child tasks inherit custom fields from their parent ticket, with task-specific fields overriding inherited values. | To enable consistent field management while allowing task-specific customizations. |
-| **PROD-010** | Query-Based Pull Synchronization | The system **MUST** support pulling tickets from Jira based on project, epic, or custom JQL queries, converting them to the canonical Markdown format. | To enable bidirectional synchronization and maintain Markdown as the source of truth. |
-| **PROD-201** | Generic `TICKET` Markdown Schema | The system **MUST** recognize and parse `# TICKET:` blocks with structured `## Description`, `## Fields`, `## Acceptance Criteria`, and `## Tasks` sections. | To support any Jira issue type and custom field configuration. |
-| **PROD-202** | Hierarchical Field Inheritance Logic | The system **MUST** calculate final fields for tasks by merging task-specific fields over parent ticket fields. | To ensure consistent field inheritance while allowing task-level overrides. |
-| **PROD-203** | Dynamic Field Mapping | The system **MUST** support configurable field mappings between human-readable names and JIRA field IDs, with automatic type conversion for number and array fields. | To support different JIRA configurations and custom fields across instances. |
-| **PROD-204** | State-Based Change Detection | The system **MUST** track content hashes of tickets to skip unchanged items during push operations. | To minimize API calls and improve performance for large ticket sets. |
-| **PROD-205** | Query-Based Ticket Pulling | The system **MUST** construct JQL queries combining project filters with user-provided JQL for flexible ticket retrieval. | To enable targeted synchronization of specific ticket subsets. |
-| **PROD-206** | Markdown Rendering | The system **MUST** convert JIRA tickets to well-formed Markdown documents preserving all field mappings and hierarchy. | To maintain bidirectional format consistency. |
-
----
-
-## 2. User Interaction Requirements
-
-*Defines how a user interacts with the system. Focuses on usability and user-facing workflows.*
-
-| ID | Title | Description | Rationale |
-| :--- | :--- | :--- | :--- |
-| **USER-001** | Non-Interactive Error Handling | The system **MUST** process all tickets and provide a comprehensive summary report, exiting with an error code if any failures occurred. The system **MUST NOT** stop processing on the first error, but rather continue attempting all tickets and aggregate the results. | To ensure all valid tickets are processed even if some fail, providing complete visibility into what succeeded and what failed. |
-| **USER-002** | Detailed Execution Report | The final summary report **MUST** include: 1. A list of all successfully created/updated items with direct links to them in the Jira UI. 2. A list of all failed items with a clear reason for failure and their source line number. | To provide a clear, actionable summary of the outcome, enabling users to verify results and debug failures efficiently. |
-| **USER-003** | Verbose Output Mode | The system **SHOULD** provide a command-line flag (e.g., `--verbose`) that prints a detailed, real-time log of all operations to the console. | To allow users to monitor the application's progress in detail during execution for debugging or observation. |
-| **USER-004** | Jira Schema Discovery | The system **MUST** provide a `schema` command that discovers and generates field mappings from the connected Jira instance. | To automate configuration and support different Jira instances with varying custom fields. |
-| **USER-005** | Configurable Pull Verbosity | The system **MUST** allow users to configure which fields are pulled from Jira through the configuration file. | To reduce noise and focus on relevant fields during synchronization. |
-| **USER-201** | Centralized YAML Configuration & CLI | The system **MUST** read configuration from `.ticketr.yaml` files using Viper, supporting environment variable overrides. Commands **MUST** be structured as `ticketr push`, `ticketr pull`, and `ticketr schema`. | To provide flexible, maintainable configuration and intuitive command structure. |
-
----
-
-## 3. Architectural Requirements
-
-*Defines high-level, non-negotiable design principles and structural constraints.*
-
-| ID | Title | Description | Rationale |
-| :--- | :--- | :--- | :--- |
-| **ARCH-001** | Ports & Adapters | The system architecture **MUST** adhere to the Ports and Adapters (Hexagonal) pattern for its core components. | To decouple the core application logic from external concerns (like the Jira API or the command-line interface), improving testability, maintainability, and flexibility. |
-
----
-
-## 4. Non-Functional Requirements (NFRs)
-
-*Defines the quality attributes and operational characteristics of the system. The "-ilities".*
-
-| ID | Title | Description | Rationale |
-| :--- | :--- | :--- | :--- |
-| **NFR-001** | **Security:** Flexible Credentials | The system **MUST** support Jira credentials from environment variables or `.ticketr.yaml` configuration, with environment variables taking precedence. | To provide flexible secret management suitable for both local development and containerized deployments. |
-| **NFR-002** | **Reliability:** Graceful API Error Handling | The system **MUST** gracefully handle and report API errors from Jira related to user permissions and project-specific validation rules. | To provide clear, actionable feedback to the user when an API call fails, enabling them to diagnose and resolve the underlying issue in Jira. |
-| **NFR-201** | **Final SRS Conformance** | The system **MUST** implement hierarchical validation, file-based logging, and enhanced reporting as specified in the modernization plan section 4. | To complete all v2.0 requirements including validation services, comprehensive logging, and detailed execution reports. |
-
----
-
-## 5. Technology & Platform Requirements
-
-*Defines the specific technologies, frameworks, and platforms that are mandated for use.*
-
-| ID | Title | Description | Rationale |
-| :--- | :--- | :--- | :--- |
-| **TECH-P-001** | **Primary Language:** Go | The application's backend **MUST** be implemented using Go. | To build a performant, single-binary executable that is well-suited for containerized, command-line applications. |
-
----
-
-## 6. Operational & DevOps Requirements
-
-*Defines the rules and constraints governing the development workflow, build process, and execution environment.*
-
-| ID | Title | Description | Rationale |
-| :--- | :--- | :--- | :--- |
-| **DEV-001** | **Containerized Execution** | The application **MUST** be distributed and executed as a Docker container. | To ensure a consistent, portable, and isolated execution environment, abstracting away the host machine's configuration. |
-
-
-
-package filesystem
-
-import (
- "bufio"
- "fmt"
- "os"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
- "github.com/karolswdev/ticktr/internal/parser"
-)
-
-// FileRepository implements the Repository port for file-based storage
-type FileRepository struct {
- parser *parser.Parser
-}
-
-// NewFileRepository creates a new instance of FileRepository
-func NewFileRepository() *FileRepository {
- return &FileRepository{
- parser: parser.New(),
- }
-}
-
-// GetTickets reads and parses tickets from a file using the new parser
-func (r *FileRepository) GetTickets(filepath string) ([]domain.Ticket, error) {
- return r.parser.Parse(filepath)
-}
-
-// SaveTickets writes tickets to a file in the new TICKET format
-func (r *FileRepository) SaveTickets(filepath string, tickets []domain.Ticket) error {
- file, err := os.Create(filepath)
- if err != nil {
- return fmt.Errorf("failed to create file: %w", err)
- }
- defer file.Close()
-
- writer := bufio.NewWriter(file)
-
- for i, ticket := range tickets {
- // Write ticket heading with Jira ID if present
- if ticket.JiraID != "" {
- fmt.Fprintf(writer, "# TICKET: [%s] %s\n", ticket.JiraID, ticket.Title)
- } else {
- fmt.Fprintf(writer, "# TICKET: %s\n", ticket.Title)
- }
- fmt.Fprintln(writer)
-
- // Write description
- if ticket.Description != "" {
- fmt.Fprintln(writer, "## Description")
- fmt.Fprintln(writer, ticket.Description)
- fmt.Fprintln(writer)
- }
-
- // Write fields
- if len(ticket.CustomFields) > 0 {
- fmt.Fprintln(writer, "## Fields")
- for key, value := range ticket.CustomFields {
- fmt.Fprintf(writer, "%s: %s\n", key, value)
- }
- fmt.Fprintln(writer)
- }
-
- // Write acceptance criteria
- if len(ticket.AcceptanceCriteria) > 0 {
- fmt.Fprintln(writer, "## Acceptance Criteria")
- for _, ac := range ticket.AcceptanceCriteria {
- fmt.Fprintf(writer, "- %s\n", ac)
- }
- fmt.Fprintln(writer)
- }
-
- // Write tasks
- if len(ticket.Tasks) > 0 {
- fmt.Fprintln(writer, "## Tasks")
- for _, task := range ticket.Tasks {
- // Write task with Jira ID if present
- if task.JiraID != "" {
- fmt.Fprintf(writer, "- [%s] %s\n", task.JiraID, task.Title)
- } else {
- fmt.Fprintf(writer, "- %s\n", task.Title)
- }
-
- // Write task description (indented)
- if task.Description != "" {
- fmt.Fprintln(writer, " ## Description")
- // Indent description lines
- lines := fmt.Sprintf("%s", task.Description)
- fmt.Fprintf(writer, " %s\n", lines)
- fmt.Fprintln(writer)
- }
-
- // Write task fields (indented)
- if len(task.CustomFields) > 0 {
- fmt.Fprintln(writer, " ## Fields")
- for key, value := range task.CustomFields {
- fmt.Fprintf(writer, " %s: %s\n", key, value)
- }
- fmt.Fprintln(writer)
- }
-
- // Write task acceptance criteria (indented)
- if len(task.AcceptanceCriteria) > 0 {
- fmt.Fprintln(writer, " ## Acceptance Criteria")
- for _, ac := range task.AcceptanceCriteria {
- fmt.Fprintf(writer, " - %s\n", ac)
- }
- fmt.Fprintln(writer)
- }
- }
- }
-
- // Add spacing between tickets
- if i < len(tickets)-1 {
- fmt.Fprintln(writer)
- }
- }
-
- return writer.Flush()
-}
-
-
-
-package jira
-
-import (
- "bytes"
- "encoding/json"
- "io"
- "net/http"
- "os"
- "strings"
- "testing"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
-)
-
-// Test Case TC-2.1: JiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully
-func TestJiraAdapter_NewClient_WithEnvVars_AuthenticatesSuccessfully(t *testing.T) {
- // Skip this test if environment variables are not set (for CI/CD)
- if os.Getenv("JIRA_URL") == "" {
- t.Skip("Skipping integration test: JIRA_URL not set")
- }
-
- // Arrange: Valid Jira credentials should be set in environment variables
- // The test assumes these are already set: JIRA_URL, JIRA_EMAIL, JIRA_API_KEY, JIRA_PROJECT_KEY
-
- // Act: Create a new Jira client instance
- adapter, err := NewJiraAdapter()
- if err != nil {
- t.Fatalf("Failed to create Jira adapter: %v", err)
- }
-
- // Assert: The client authenticates successfully
- err = adapter.Authenticate()
- if err != nil {
- t.Errorf("Authentication failed: %v", err)
- }
-}
-
-// Test Case TC-2.2: JiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID
-func TestJiraAdapter_CreateStory_ValidStory_ReturnsNewJiraID(t *testing.T) {
- // Skip this test if environment variables are not set (for CI/CD)
- if os.Getenv("JIRA_URL") == "" {
- t.Skip("Skipping integration test: JIRA_URL not set")
- }
-
- // Arrange: Create a valid Ticket domain object
- ticket := domain.Ticket{
- Title: "Test Ticket from Integration Test",
- Description: "This is a test ticket created by the integration test suite",
- AcceptanceCriteria: []string{
- "The ticket should be created in Jira",
- "A valid Jira ID should be returned",
- },
- CustomFields: map[string]string{},
- Tasks: []domain.Task{},
- }
-
- adapter, err := NewJiraAdapter()
- if err != nil {
- t.Fatalf("Failed to create Jira adapter: %v", err)
- }
-
- // Act: Call the CreateTicket method on the Jira adapter
- jiraID, err := adapter.CreateTicket(ticket)
-
- // Assert: The method returns a valid, non-empty Jira Issue Key
- if err != nil {
- t.Fatalf("Failed to create story: %v", err)
- }
-
- if jiraID == "" {
- t.Error("Expected non-empty Jira ID, got empty string")
- }
-
- // Log the created Jira ID for manual verification if needed
- t.Logf("Successfully created story with Jira ID: %s", jiraID)
-}
-
-// Test Case TC-3.1: JiraAdapter_UpdateStory_ValidStoryWithID_Succeeds
-func TestJiraAdapter_UpdateStory_ValidStoryWithID_Succeeds(t *testing.T) {
- // Skip this test if environment variables are not set (for CI/CD)
- if os.Getenv("JIRA_URL") == "" {
- t.Skip("Skipping integration test: JIRA_URL not set")
- }
-
- // Arrange: Create a story in Jira to get a valid ID
- adapter, err := NewJiraAdapter()
- if err != nil {
- t.Fatalf("Failed to create Jira adapter: %v", err)
- }
-
- // First create a ticket to update
- initialTicket := domain.Ticket{
- Title: "Test Ticket for Update Integration Test",
- Description: "Initial description for update test",
- AcceptanceCriteria: []string{
- "Initial acceptance criterion",
- },
- CustomFields: map[string]string{},
- Tasks: []domain.Task{},
- }
-
- jiraID, err := adapter.CreateTicket(initialTicket)
- if err != nil {
- t.Fatalf("Failed to create initial ticket: %v", err)
- }
- t.Logf("Created ticket with Jira ID: %s", jiraID)
-
- // Create a Ticket domain object with that ID and modified description
- updatedTicket := domain.Ticket{
- JiraID: jiraID,
- Title: "Updated Test Ticket from Integration Test",
- Description: "This description has been updated by the integration test",
- AcceptanceCriteria: []string{
- "Updated acceptance criterion 1",
- "Updated acceptance criterion 2",
- },
- CustomFields: map[string]string{},
- Tasks: []domain.Task{},
- }
-
- // Act: Call the UpdateTicket method on the Jira adapter
- err = adapter.UpdateTicket(updatedTicket)
-
- // Assert: The method succeeds and the description in Jira is updated
- if err != nil {
- t.Errorf("Failed to update ticket: %v", err)
- } else {
- t.Logf("Successfully updated ticket with Jira ID: %s", jiraID)
- }
-}
-
-// Test Case TC-205.1: TestJiraAdapter_SearchTickets_ConstructsJql
-func TestJiraAdapter_SearchTickets_ConstructsJql(t *testing.T) {
- // Arrange: Mock the http.Client
- var capturedRequest *http.Request
- mockTransport := &MockRoundTripper{
- RoundTripFunc: func(req *http.Request) (*http.Response, error) {
- // Capture the request for assertions
- capturedRequest = req
-
- // Return a mock response
- responseBody := `{
- "issues": [],
- "total": 0,
- "maxResults": 100,
- "startAt": 0
- }`
- return &http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(responseBody)),
- }, nil
- },
- }
-
- // Create JiraAdapter with mocked client
- adapter := &JiraAdapter{
- baseURL: "https://test.atlassian.net",
- email: "test@example.com",
- apiKey: "test-api-key",
- projectKey: "PROJ",
- storyType: "Task",
- subTaskType: "Sub-task",
- client: &http.Client{Transport: mockTransport},
- fieldMappings: getDefaultFieldMappings(),
- }
-
- // Act: Call SearchTickets with a project key "PROJ" and JQL "status=Done"
- _, err := adapter.SearchTickets("PROJ", "status=Done")
-
- // Assert: The request sent to Jira's /rest/api/2/search endpoint contains the JQL
- if err != nil {
- t.Fatalf("SearchTickets returned error: %v", err)
- }
-
- // Verify the request was sent to correct endpoint
- expectedURL := "https://test.atlassian.net/rest/api/2/search"
- if capturedRequest == nil {
- t.Fatal("No request was captured")
- }
- if capturedRequest.URL.String() != expectedURL {
- t.Errorf("Expected URL %s, got %s", expectedURL, capturedRequest.URL.String())
- }
-
- // Read and verify the request body contains correct JQL
- bodyBytes, err := io.ReadAll(capturedRequest.Body)
- if err != nil {
- t.Fatalf("Failed to read request body: %v", err)
- }
-
- // Parse the JSON body to verify JQL
- var requestBody map[string]interface{}
- if err := json.Unmarshal(bodyBytes, &requestBody); err != nil {
- t.Fatalf("Failed to parse request body JSON: %v", err)
- }
-
- expectedJQL := `project = "PROJ" AND status=Done`
- actualJQL, ok := requestBody["jql"].(string)
- if !ok {
- t.Fatal("Request body does not contain 'jql' field")
- }
-
- if actualJQL != expectedJQL {
- t.Errorf("JQL mismatch.\nExpected: %s\nActual: %s", expectedJQL, actualJQL)
- }
-
- // Verify request has proper authentication header
- authHeader := capturedRequest.Header.Get("Authorization")
- if !strings.HasPrefix(authHeader, "Basic ") {
- t.Errorf("Expected Basic auth header, got: %s", authHeader)
- }
-
- // Verify content type
- contentType := capturedRequest.Header.Get("Content-Type")
- if contentType != "application/json" {
- t.Errorf("Expected Content-Type: application/json, got: %s", contentType)
- }
-
- t.Logf("Successfully verified JQL construction: %s", expectedJQL)
-}
-
-
-
-package domain
-
-type Ticket struct {
- Title string
- Description string
- CustomFields map[string]string
- AcceptanceCriteria []string
- JiraID string
- Tasks []Task
- SourceLine int
-}
-
-type Task struct {
- Title string
- Description string
- CustomFields map[string]string // Task-specific overrides
- AcceptanceCriteria []string
- JiraID string
- SourceLine int
-}
-
-
-
-# Environment variables
-.env
-.env.local
-.env.*.local
-
-# Binaries for programs and plugins
-*.exe
-*.exe~
-*.dll
-*.so
-*.dylib
-/ticketr
-/jira-story-creator
-
-# Test binary, built with `go test -c`
-*.test
-
-# Output of the go coverage tool
-*.out
-
-# Go workspace file
-go.work
-
-# Dependency directories
-vendor/
-
-# IDE specific files
-.idea/
-*.swp
-*.swo
-*~
-.vscode/
-*.iml
-
-# OS specific files
-.DS_Store
-Thumbs.db
-
-# Temporary files
-*.tmp
-*.bak
-*.backup
-tmp/
-
-# Local configuration
-config.local.yaml
-config.local.json
-
-# Project documentation and templates (development only)
-PHASE-*.md
-STORY-MARKDOWN-SPEC.md
-BACKLOG.md
-
-# Claude AI settings
-.claude/
-
-# Build artifacts
-dist/
-build/
-
-
-
-package ports
-
-import "github.com/karolswdev/ticktr/internal/core/domain"
-
-// JiraPort defines the interface for Jira integration operations
-type JiraPort interface {
- // Authenticate verifies the connection to Jira with the provided credentials
- Authenticate() error
-
- // CreateTask creates a new sub-task in Jira under the specified parent
- CreateTask(task domain.Task, parentID string) (string, error)
-
- // UpdateTask updates an existing task in Jira
- UpdateTask(task domain.Task) error
-
- // GetProjectIssueTypes fetches available issue types for the configured project
- GetProjectIssueTypes() (map[string][]string, error)
-
- // GetIssueTypeFields fetches field requirements for a specific issue type
- GetIssueTypeFields(issueTypeName string) (map[string]interface{}, error)
-
- // CreateTicket creates a new ticket in Jira with dynamic field mapping
- CreateTicket(ticket domain.Ticket) (string, error)
-
- // UpdateTicket updates an existing ticket in Jira with dynamic field mapping
- UpdateTicket(ticket domain.Ticket) error
-
- // SearchTickets searches for tickets in Jira using JQL query
- SearchTickets(projectKey string, jql string) ([]domain.Ticket, error)
-}
-
-
-
-package main
-
-import (
- "errors"
- "fmt"
- "log"
- "os"
- "strings"
-
- "github.com/spf13/cobra"
- "github.com/spf13/viper"
- "github.com/karolswdev/ticktr/internal/adapters/filesystem"
- "github.com/karolswdev/ticktr/internal/adapters/jira"
- "github.com/karolswdev/ticktr/internal/core/services"
- "github.com/karolswdev/ticktr/internal/core/validation"
- "github.com/karolswdev/ticktr/internal/state"
- "github.com/karolswdev/ticktr/internal/renderer"
-)
-
-var (
- cfgFile string
- verbose bool
- forcePartialUpload bool
-
- // Pull command flags
- pullProject string
- pullEpic string
- pullJQL string
- pullOutput string
-
- rootCmd = &cobra.Command{
- Use: "ticketr",
- Short: "A tool for managing JIRA tickets as code",
- Long: `Ticketr is a command-line tool that allows you to manage JIRA tickets
-using Markdown files stored in version control.`,
- }
-
- pushCmd = &cobra.Command{
- Use: "push [file]",
- Short: "Push tickets from Markdown to JIRA",
- Long: `Read tickets from a Markdown file and create or update them in JIRA.`,
- Args: cobra.ExactArgs(1),
- Run: runPush,
- }
-
- pullCmd = &cobra.Command{
- Use: "pull",
- Short: "Pull tickets from JIRA to Markdown",
- Long: `Fetch tickets from JIRA and write them to a Markdown file.`,
- Run: runPull,
- }
-
- schemaCmd = &cobra.Command{
- Use: "schema",
- Short: "Discover JIRA schema and generate configuration",
- Long: `Connect to JIRA and generate field mappings for .ticketr.yaml configuration.`,
- Run: runSchema,
- }
-
- // Legacy commands for backward compatibility
- legacyCmd = &cobra.Command{
- Use: "legacy",
- Hidden: true,
- Run: runLegacy,
- }
-)
-
-func init() {
- cobra.OnInitialize(initConfig)
-
- // Global flags
- rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is .ticketr.yaml)")
- rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose logging")
-
- // Push command flags
- pushCmd.Flags().BoolVar(&forcePartialUpload, "force-partial-upload", false, "continue processing even if some items fail")
-
- // Pull command flags
- pullCmd.Flags().StringVar(&pullProject, "project", "", "JIRA project key to pull from")
- pullCmd.Flags().StringVar(&pullEpic, "epic", "", "JIRA epic key to pull tickets from")
- pullCmd.Flags().StringVar(&pullJQL, "jql", "", "JQL query to filter tickets")
- pullCmd.Flags().StringVarP(&pullOutput, "output", "o", "pulled_tickets.md", "output file path")
-
- // Add commands to root
- rootCmd.AddCommand(pushCmd)
- rootCmd.AddCommand(pullCmd)
- rootCmd.AddCommand(schemaCmd)
- rootCmd.AddCommand(legacyCmd)
-
- // Legacy flags for backward compatibility
- rootCmd.PersistentFlags().StringP("file", "f", "", "Path to the input Markdown file (deprecated, use 'push' command)")
- rootCmd.PersistentFlags().Bool("list-issue-types", false, "List available issue types (deprecated)")
- rootCmd.PersistentFlags().String("check-fields", "", "Check required fields for issue type (deprecated)")
- rootCmd.PersistentFlags().MarkHidden("file")
- rootCmd.PersistentFlags().MarkHidden("list-issue-types")
- rootCmd.PersistentFlags().MarkHidden("check-fields")
-}
-
-func initConfig() {
- if cfgFile != "" {
- viper.SetConfigFile(cfgFile)
- } else {
- // Search for config in current directory
- viper.AddConfigPath(".")
- viper.SetConfigName(".ticketr")
- viper.SetConfigType("yaml")
- }
-
- // Environment variables override config
- viper.SetEnvPrefix("JIRA")
- viper.AutomaticEnv()
-
- // Read config file if it exists
- if err := viper.ReadInConfig(); err == nil {
- if verbose {
- log.Printf("Using config file: %s", viper.ConfigFileUsed())
- }
- }
-
- // Configure logging
- if verbose {
- log.SetFlags(log.Ltime | log.Lshortfile | log.Lmicroseconds)
- log.Println("Verbose mode enabled")
- } else {
- log.SetFlags(log.Ltime)
- }
-}
-
-func runPush(cmd *cobra.Command, args []string) {
- inputFile := args[0]
-
- // Initialize repository
- repo := filesystem.NewFileRepository()
-
- // Pre-flight validation: Parse tickets first for validation
- tickets, err := repo.GetTickets(inputFile)
- if err != nil {
- fmt.Printf("Error reading tickets from file: %v\n", err)
- os.Exit(1)
- }
-
- // Initialize validator and run pre-flight validation
- validator := validation.NewValidator()
- validationErrors := validator.ValidateTickets(tickets)
- if len(validationErrors) > 0 {
- fmt.Println("Validation errors found:")
- for _, vErr := range validationErrors {
- fmt.Printf(" - %s\n", vErr.Error())
- }
- fmt.Printf("\n%d validation error(s) found. Fix these issues before pushing to JIRA.\n", len(validationErrors))
- os.Exit(1)
- }
-
- // Initialize Jira adapter
- jiraAdapter, err := jira.NewJiraAdapter()
- if err != nil {
- fmt.Printf("Error initializing Jira adapter: %v\n", err)
- fmt.Println("\nMake sure the following environment variables are set:")
- fmt.Println(" - JIRA_URL")
- fmt.Println(" - JIRA_EMAIL")
- fmt.Println(" - JIRA_API_KEY")
- fmt.Println(" - JIRA_PROJECT_KEY")
- fmt.Println("\nOptional environment variables:")
- fmt.Println(" - JIRA_STORY_TYPE (defaults to 'Task')")
- fmt.Println(" - JIRA_SUBTASK_TYPE (defaults to 'Sub-task')")
- os.Exit(1)
- }
-
- // Initialize service
- service := services.NewTicketService(repo, jiraAdapter)
-
- // Process tickets
- options := services.ProcessOptions{
- ForcePartialUpload: forcePartialUpload,
- }
-
- result, err := service.ProcessTicketsWithOptions(inputFile, options)
- if err != nil {
- fmt.Printf("Error processing file: %v\n", err)
- os.Exit(1)
- }
-
- // Print summary
- fmt.Println("\n=== Summary ===")
- if result.TicketsCreated > 0 {
- fmt.Printf("Tickets created: %d\n", result.TicketsCreated)
- }
- if result.TicketsUpdated > 0 {
- fmt.Printf("Tickets updated: %d\n", result.TicketsUpdated)
- }
- if result.TasksCreated > 0 {
- fmt.Printf("Tasks created: %d\n", result.TasksCreated)
- }
- if result.TasksUpdated > 0 {
- fmt.Printf("Tasks updated: %d\n", result.TasksUpdated)
- }
-
- // Print errors if any
- if len(result.Errors) > 0 {
- fmt.Printf("\n=== Errors (%d) ===\n", len(result.Errors))
- for _, err := range result.Errors {
- fmt.Printf(" - %s\n", err)
- }
-
- if !forcePartialUpload {
- os.Exit(2)
- }
- }
-
- fmt.Println("\nProcessing complete!")
-}
-
-// runPull handles the pull command
-func runPull(cmd *cobra.Command, args []string) {
- // Initialize JIRA adapter with field mappings from config
- fieldMappings := viper.GetStringMap("field_mappings")
-
- // Convert to proper format for adapter
- mappings := make(map[string]interface{})
- for key, value := range fieldMappings {
- mappings[key] = value
- }
-
- jiraAdapter, err := jira.NewJiraAdapterWithConfig(mappings)
- if err != nil {
- fmt.Printf("Error initializing JIRA adapter: %v\n", err)
- fmt.Println("\nMake sure the following environment variables are set:")
- fmt.Println(" - JIRA_URL")
- fmt.Println(" - JIRA_EMAIL")
- fmt.Println(" - JIRA_API_KEY")
- fmt.Println(" - JIRA_PROJECT_KEY")
- os.Exit(1)
- }
-
- // Get project key from flag or environment
- projectKey := pullProject
- if projectKey == "" {
- projectKey = os.Getenv("JIRA_PROJECT_KEY")
- }
- if projectKey == "" {
- fmt.Println("Error: Project key is required. Use --project flag or set JIRA_PROJECT_KEY environment variable")
- os.Exit(1)
- }
-
- // Construct JQL based on flags
- jql := pullJQL
- if pullEpic != "" {
- epicFilter := fmt.Sprintf(`"Epic Link" = "%s"`, pullEpic)
- if jql != "" {
- jql = fmt.Sprintf("%s AND %s", jql, epicFilter)
- } else {
- jql = epicFilter
- }
- }
-
- // Log the query if verbose
- if verbose {
- log.Printf("Pulling tickets from project: %s", projectKey)
- if jql != "" {
- log.Printf("Using JQL filter: %s", jql)
- }
- }
-
- // Check if output file exists to enable conflict detection
- fileRepo := filesystem.NewFileRepository()
- hasExistingFile := false
- var existingTickets []interface{} // We'll need to adapt this based on actual types
-
- if _, err := os.Stat(pullOutput); err == nil {
- hasExistingFile = true
- // Try to parse existing tickets for conflict detection
- if tickets, err := fileRepo.GetTickets(pullOutput); err == nil {
- existingTickets = make([]interface{}, len(tickets))
- for i, t := range tickets {
- existingTickets[i] = t
- }
- }
- }
-
- // Search for tickets from JIRA
- tickets, err := jiraAdapter.SearchTickets(projectKey, jql)
- if err != nil {
- fmt.Printf("Error searching tickets: %v\n", err)
- os.Exit(1)
- }
-
- if len(tickets) == 0 {
- fmt.Println("No tickets found matching the query")
- return
- }
-
- // If we have advanced conflict detection available, use the pull service
- if hasExistingFile && len(existingTickets) > 0 {
- // Initialize state manager for conflict detection
- stateManager := state.NewStateManager(".ticketr.state")
-
- // Create pull service if available
- pullService := services.NewPullService(jiraAdapter, fileRepo, stateManager)
-
- // Execute intelligent pull with conflict detection
- result, err := pullService.Pull(pullOutput, services.PullOptions{
- ProjectKey: projectKey,
- JQL: jql,
- EpicKey: pullEpic,
- Force: false, // Could be a flag in the future
- })
-
- // Handle errors and conflicts
- if err != nil {
- if errors.Is(err, services.ErrConflictDetected) {
- fmt.Println("⚠️ Conflict detected! The following tickets have both local and remote changes:")
- for _, ticketID := range result.Conflicts {
- fmt.Printf(" - %s\n", ticketID)
- }
- fmt.Println("\nTo force overwrite local changes with remote changes, use --force flag")
- os.Exit(1)
- }
- fmt.Printf("Error pulling tickets: %v\n", err)
- os.Exit(1)
- }
-
- // Print summary for intelligent pull
- fmt.Printf("Successfully updated %s\n", pullOutput)
- if result.TicketsPulled > 0 {
- fmt.Printf(" - %d new ticket(s) pulled from JIRA\n", result.TicketsPulled)
- }
- if result.TicketsUpdated > 0 {
- fmt.Printf(" - %d ticket(s) updated with remote changes\n", result.TicketsUpdated)
- }
- if result.TicketsSkipped > 0 {
- fmt.Printf(" - %d ticket(s) skipped (no changes or local changes preserved)\n", result.TicketsSkipped)
- }
- return
- }
-
- // Fallback to simple render-based pull (when no existing file or state)
- // Initialize renderer with field mappings
- ticketRenderer := renderer.NewRenderer(mappings)
-
- // Render tickets to markdown
- markdown := ticketRenderer.RenderMultiple(tickets)
-
- // Write to output file
- err = os.WriteFile(pullOutput, []byte(markdown), 0644)
- if err != nil {
- fmt.Printf("Error writing output file: %v\n", err)
- os.Exit(1)
- }
-
- // Print summary for simple pull
- fmt.Printf("Successfully pulled %d ticket(s) to %s\n", len(tickets), pullOutput)
- if verbose {
- fmt.Println("\nTickets pulled:")
- for _, ticket := range tickets {
- fmt.Printf(" - [%s] %s\n", ticket.JiraID, ticket.Title)
- }
- }
-}
-
-// runSchema handles the schema discovery command
-func runSchema(cmd *cobra.Command, args []string) {
- // Initialize JIRA adapter
- jiraAdapter, err := jira.NewJiraAdapter()
- if err != nil {
- fmt.Printf("Error initializing JIRA adapter: %v\n", err)
- os.Exit(1)
- }
-
- // Get project issue types
- issueTypes, err := jiraAdapter.GetProjectIssueTypes()
- if err != nil {
- fmt.Printf("Error fetching project issue types: %v\n", err)
- os.Exit(1)
- }
-
- // Start building the YAML output
- fmt.Println("# Generated field mappings for .ticketr.yaml")
- fmt.Println("field_mappings:")
-
- // Always include standard fields
- fmt.Println(" \"Type\": \"issuetype\"")
- fmt.Println(" \"Project\": \"project\"")
- fmt.Println(" \"Summary\": \"summary\"")
- fmt.Println(" \"Description\": \"description\"")
- fmt.Println(" \"Assignee\": \"assignee\"")
- fmt.Println(" \"Reporter\": \"reporter\"")
- fmt.Println(" \"Priority\": \"priority\"")
- fmt.Println(" \"Labels\": \"labels\"")
- fmt.Println(" \"Components\": \"components\"")
- fmt.Println(" \"Fix Version\": \"fixVersions\"")
- fmt.Println(" \"Sprint\": \"customfield_10020\" # Common sprint field")
-
- // Collect custom fields from all issue types
- customFieldsMap := make(map[string]map[string]interface{})
-
- for projectKey, types := range issueTypes {
- if verbose {
- fmt.Fprintf(os.Stderr, "Processing project: %s\n", projectKey)
- }
- for _, issueType := range types {
- if verbose {
- fmt.Fprintf(os.Stderr, " Fetching fields for issue type: %s\n", issueType)
- }
-
- fields, err := jiraAdapter.GetIssueTypeFields(issueType)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Warning: Could not fetch fields for %s: %v\n", issueType, err)
- continue
- }
-
- // Process optional fields (custom fields are usually here)
- if optionalInterface, ok := fields["optional"]; ok {
- if optional, ok := optionalInterface.([]interface{}); ok {
- for _, field := range optional {
- if fieldMap, ok := field.(map[string]interface{}); ok {
- processFieldForSchema(fieldMap, customFieldsMap)
- }
- }
- }
- }
- }
- }
-
- // Output discovered custom fields
- for fieldName, fieldInfo := range customFieldsMap {
- id := fieldInfo["id"].(string)
- fieldType := fieldInfo["type"].(string)
-
- // Format based on type
- if fieldType == "string" || fieldType == "option" {
- fmt.Printf(" \"%s\": \"%s\"\n", fieldName, id)
- } else {
- fmt.Printf(" \"%s\":\n", fieldName)
- fmt.Printf(" id: \"%s\"\n", id)
- fmt.Printf(" type: \"%s\"\n", fieldType)
- }
- }
-
- // Add example sync configuration
- fmt.Println("\n# Example sync configuration")
- fmt.Println("sync:")
- fmt.Println(" pull:")
- fmt.Println(" # Fields to pull from JIRA to Markdown")
- fmt.Println(" fields:")
- fmt.Println(" - \"Story Points\"")
- fmt.Println(" - \"Sprint\"")
- fmt.Println(" - \"Priority\"")
- fmt.Println(" ignored_fields:")
- fmt.Println(" # Fields to never pull")
- fmt.Println(" - \"updated\"")
- fmt.Println(" - \"created\"")
-}
-
-// processFieldForSchema extracts relevant field information for schema generation
-func processFieldForSchema(field map[string]interface{}, customFieldsMap map[string]map[string]interface{}) {
- key, hasKey := field["key"].(string)
- if !hasKey || !strings.HasPrefix(key, "customfield_") {
- return
- }
-
- name := ""
- if nameVal, ok := field["name"]; ok {
- name = nameVal.(string)
- }
-
- if name == "" || name == "Development" || strings.Contains(name, "[CHART]") {
- return // Skip system or chart fields
- }
-
- // Determine field type
- fieldType := "string" // default
- if schema, ok := field["schema"]; ok {
- if schemaMap, ok := schema.(map[string]interface{}); ok {
- if typeVal, ok := schemaMap["type"]; ok {
- switch typeVal.(string) {
- case "number":
- fieldType = "number"
- case "array":
- fieldType = "array"
- case "option":
- fieldType = "option"
- }
- }
- }
- }
-
- // Store field info if not already present or if this is a better match
- if _, exists := customFieldsMap[name]; !exists {
- customFieldsMap[name] = map[string]interface{}{
- "id": key,
- "type": fieldType,
- }
- }
-}
-
-// runLegacy handles the old command-line interface for backward compatibility
-func runLegacy(cmd *cobra.Command, args []string) {
- // Check for legacy flags
- inputFile, _ := cmd.Flags().GetString("file")
- listIssueTypes, _ := cmd.Flags().GetBool("list-issue-types")
- checkFields, _ := cmd.Flags().GetString("check-fields")
-
- // Initialize Jira adapter for legacy commands
- jiraAdapter, err := jira.NewJiraAdapter()
- if err != nil {
- fmt.Printf("Error initializing Jira adapter: %v\n", err)
- os.Exit(1)
- }
-
- // Handle list-issue-types
- if listIssueTypes {
- fmt.Println("Fetching issue types from JIRA...")
- issueTypesInfo, err := jiraAdapter.GetProjectIssueTypes()
- if err != nil {
- fmt.Printf("Error fetching issue types: %v\n", err)
- os.Exit(1)
- }
-
- fmt.Println("\n" + "=" + string(make([]byte, 50)))
- if projectName, ok := issueTypesInfo["project"]; ok && len(projectName) > 0 {
- fmt.Printf("Project: %s", projectName[0])
- if key, ok := issueTypesInfo["key"]; ok && len(key) > 0 {
- fmt.Printf(" (%s)\n", key[0])
- }
- }
- fmt.Println("=" + string(make([]byte, 50)))
-
- if issueTypes, ok := issueTypesInfo["types"]; ok {
- fmt.Println("\nAvailable Issue Types:")
- for _, issueType := range issueTypes {
- fmt.Printf(" - %s\n", issueType)
- }
- }
-
- if subtaskTypes, ok := issueTypesInfo["subtasks"]; ok && len(subtaskTypes) > 0 {
- fmt.Println("\nAvailable Subtask Types:")
- for _, subtaskType := range subtaskTypes {
- fmt.Printf(" - %s\n", subtaskType)
- }
- }
- return
- }
-
- // Handle check-fields
- if checkFields != "" {
- fmt.Printf("Checking fields for issue type: %s\n", checkFields)
- fields, err := jiraAdapter.GetIssueTypeFields(checkFields)
- if err != nil {
- fmt.Printf("Error fetching fields: %v\n", err)
- os.Exit(1)
- }
-
- fmt.Printf("\n%s Issue Type Fields:\n", checkFields)
- fmt.Println("=" + string(make([]byte, 50)))
-
- if requiredInterface, ok := fields["required"]; ok {
- if required, ok := requiredInterface.([]interface{}); ok && len(required) > 0 {
- fmt.Println("\nRequired Fields:")
- for _, field := range required {
- if fieldMap, ok := field.(map[string]interface{}); ok {
- printFieldInfo(fieldMap)
- }
- }
- }
- }
-
- if optionalInterface, ok := fields["optional"]; ok {
- if optional, ok := optionalInterface.([]interface{}); ok && len(optional) > 0 {
- fmt.Println("\nOptional Fields:")
- for _, field := range optional {
- if fieldMap, ok := field.(map[string]interface{}); ok {
- printFieldInfo(fieldMap)
- }
- }
- }
- }
- return
- }
-
- // Handle file processing (default behavior)
- if inputFile != "" {
- runPush(cmd, []string{inputFile})
- return
- }
-
- // No valid command provided
- cmd.Help()
-}
-
-// printFieldInfo prints formatted field information
-func printFieldInfo(field map[string]interface{}) {
- key := field["key"].(string)
- name := ""
- if n, ok := field["name"].(string); ok {
- name = n
- }
-
- fieldType := ""
- if t, ok := field["type"].(string); ok {
- fieldType = t
- if items, ok := field["items"].(string); ok {
- fieldType = fmt.Sprintf("%s[%s]", fieldType, items)
- }
- }
-
- fmt.Printf("\n %s (%s)\n", name, key)
- if fieldType != "" {
- fmt.Printf(" Type: %s\n", fieldType)
- }
-
- if values, ok := field["allowedValues"].([]string); ok && len(values) > 0 {
- fmt.Printf(" Allowed Values: %s\n", strings.Join(values, ", "))
- if len(values) > 5 {
- fmt.Printf(" (showing first 5 of %d values)\n", len(values))
- }
- }
-}
-
-func main() {
- // Check for legacy usage (no subcommand)
- if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
- // If first arg is not a flag and not a known command, assume it's a file (legacy)
- knownCommands := []string{"push", "pull", "schema", "help", "completion"}
- isKnownCommand := false
- for _, cmd := range knownCommands {
- if os.Args[1] == cmd {
- isKnownCommand = true
- break
- }
- }
-
- if !isKnownCommand && !strings.HasPrefix(os.Args[1], "-") {
- // Legacy mode: no subcommand, treat as file argument
- // This maintains backward compatibility
- }
- } else if len(os.Args) == 1 {
- // No arguments at all, show help
- rootCmd.Help()
- return
- } else {
- // Check for legacy flags without subcommand
- hasLegacyFlag := false
- for _, arg := range os.Args[1:] {
- if strings.Contains(arg, "-file") || strings.Contains(arg, "-f=") ||
- strings.Contains(arg, "list-issue-types") || strings.Contains(arg, "check-fields") {
- hasLegacyFlag = true
- break
- }
- }
-
- if hasLegacyFlag {
- // Use legacy command handler
- runLegacy(rootCmd, os.Args[1:])
- return
- }
- }
-
- if err := rootCmd.Execute(); err != nil {
- fmt.Println(err)
- os.Exit(1)
- }
-}
-
-
-
-package jira
-
-import (
- "bytes"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "os"
- "strings"
-
- "github.com/karolswdev/ticktr/internal/core/domain"
- "github.com/karolswdev/ticktr/internal/core/ports"
-)
-
-// JiraAdapter implements the JiraPort interface for Jira API integration
-type JiraAdapter struct {
- baseURL string
- email string
- apiKey string
- projectKey string
- storyType string
- subTaskType string
- client *http.Client
- fieldMappings map[string]interface{} // Maps human-readable names to JIRA field IDs
-}
-
-// NewJiraAdapter creates a new instance of JiraAdapter using environment variables
-func NewJiraAdapter() (ports.JiraPort, error) {
- return NewJiraAdapterWithConfig(nil)
-}
-
-// NewJiraAdapterWithConfig creates a new instance of JiraAdapter with custom field mappings
-func NewJiraAdapterWithConfig(fieldMappings map[string]interface{}) (ports.JiraPort, error) {
- baseURL := os.Getenv("JIRA_URL")
- email := os.Getenv("JIRA_EMAIL")
- apiKey := os.Getenv("JIRA_API_KEY")
- projectKey := os.Getenv("JIRA_PROJECT_KEY")
-
- if baseURL == "" || email == "" || apiKey == "" || projectKey == "" {
- return nil, fmt.Errorf("missing required environment variables: JIRA_URL, JIRA_EMAIL, JIRA_API_KEY, JIRA_PROJECT_KEY")
- }
-
- // Get issue types from environment, with sensible defaults
- storyType := os.Getenv("JIRA_STORY_TYPE")
- if storyType == "" {
- storyType = "Task" // Default to Task which is more common
- }
-
- subTaskType := os.Getenv("JIRA_SUBTASK_TYPE")
- if subTaskType == "" {
- subTaskType = "Sub-task" // Standard JIRA subtask type
- }
-
- // If no field mappings provided, use defaults
- if fieldMappings == nil {
- fieldMappings = getDefaultFieldMappings()
- }
-
- // Ensure base URL doesn't have trailing slash
- baseURL = strings.TrimRight(baseURL, "/")
-
- return &JiraAdapter{
- baseURL: baseURL,
- email: email,
- apiKey: apiKey,
- projectKey: projectKey,
- storyType: storyType,
- subTaskType: subTaskType,
- client: &http.Client{},
- fieldMappings: fieldMappings,
- }, nil
-}
-
-// getDefaultFieldMappings returns default field mappings for JIRA
-func getDefaultFieldMappings() map[string]interface{} {
- return map[string]interface{}{
- "Type": "issuetype",
- "Project": "project",
- "Summary": "summary",
- "Description": "description",
- "Assignee": "assignee",
- "Reporter": "reporter",
- "Priority": "priority",
- "Labels": "labels",
- "Components": "components",
- "Fix Version": "fixVersions",
- "Sprint": "customfield_10020",
- "Story Points": map[string]interface{}{
- "id": "customfield_10010",
- "type": "number",
- },
- }
-}
-
-// getAuthHeader returns the base64 encoded authentication header value
-func (j *JiraAdapter) getAuthHeader() string {
- auth := fmt.Sprintf("%s:%s", j.email, j.apiKey)
- return base64.StdEncoding.EncodeToString([]byte(auth))
-}
-
-// Authenticate verifies the connection to Jira with the provided credentials
-func (j *JiraAdapter) Authenticate() error {
- // Use the myself endpoint to verify authentication
- url := fmt.Sprintf("%s/rest/api/2/myself", j.baseURL)
-
- req, err := http.NewRequest("GET", url, nil)
- if err != nil {
- return fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Authorization", fmt.Sprintf("Basic %s", j.getAuthHeader()))
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := j.client.Do(req)
- if err != nil {
- return fmt.Errorf("failed to execute request: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("authentication failed with status %d: %s", resp.StatusCode, string(body))
- }
-
- return nil
-}
-
-
-// CreateTask creates a new sub-task in Jira under the specified parent story
-func (j *JiraAdapter) CreateTask(task domain.Task, parentID string) (string, error) {
- // Build the description with acceptance criteria
- description := task.Description
- if len(task.AcceptanceCriteria) > 0 {
- description += "\n\nh3. Acceptance Criteria\n"
- for _, ac := range task.AcceptanceCriteria {
- description += fmt.Sprintf("* %s\n", ac)
- }
- }
-
- // Create the request payload
- payload := map[string]interface{}{
- "fields": map[string]interface{}{
- "project": map[string]string{
- "key": j.projectKey,
- },
- "summary": task.Title,
- "description": description,
- "issuetype": map[string]string{
- "name": j.subTaskType,
- },
- "parent": map[string]string{
- "key": parentID,
- },
- },
- }
-
- jsonPayload, err := json.Marshal(payload)
- if err != nil {
- return "", fmt.Errorf("failed to marshal payload: %w", err)
- }
-
- url := fmt.Sprintf("%s/rest/api/2/issue", j.baseURL)
- req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload))
- if err != nil {
- return "", fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Authorization", fmt.Sprintf("Basic %s", j.getAuthHeader()))
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := j.client.Do(req)
- if err != nil {
- return "", fmt.Errorf("failed to execute request: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", fmt.Errorf("failed to read response body: %w", err)
- }
-
- if resp.StatusCode != http.StatusCreated {
- return "", fmt.Errorf("failed to create task with status %d: %s", resp.StatusCode, string(body))
- }
-
- // Parse the response to get the issue key
- var result map[string]interface{}
- if err := json.Unmarshal(body, &result); err != nil {
- return "", fmt.Errorf("failed to parse response: %w", err)
- }
-
- key, ok := result["key"].(string)
- if !ok {
- return "", fmt.Errorf("response did not contain issue key")
- }
-
- return key, nil
-}
-
-
-// GetProjectIssueTypes fetches available issue types for the configured project
-func (j *JiraAdapter) GetProjectIssueTypes() (map[string][]string, error) {
- url := fmt.Sprintf("%s/rest/api/2/project/%s", j.baseURL, j.projectKey)
-
- req, err := http.NewRequest("GET", url, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Authorization", fmt.Sprintf("Basic %s", j.getAuthHeader()))
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := j.client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to execute request: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("failed to get project with status %d: %s", resp.StatusCode, string(body))
- }
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
-
- // Parse the response
- var project map[string]interface{}
- if err := json.Unmarshal(body, &project); err != nil {
- return nil, fmt.Errorf("failed to parse response: %w", err)
- }
-
- result := make(map[string][]string)
-
- // Get project name
- if name, ok := project["name"].(string); ok {
- result["project"] = []string{name}
- }
-
- // Get project key
- if key, ok := project["key"].(string); ok {
- result["key"] = []string{key}
- }
-
- // Get issue types
- issueTypes := []string{}
- if types, ok := project["issueTypes"].([]interface{}); ok {
- for _, t := range types {
- if typeMap, ok := t.(map[string]interface{}); ok {
- if name, ok := typeMap["name"].(string); ok {
- // Check if it's a subtask type
- if subtask, ok := typeMap["subtask"].(bool); ok && subtask {
- issueTypes = append(issueTypes, fmt.Sprintf("%s (subtask)", name))
- } else {
- issueTypes = append(issueTypes, name)
- }
- }
- }
- }
- }
- result["issueTypes"] = issueTypes
-
- return result, nil
-}
-
-// GetIssueTypeFields fetches field requirements for a specific issue type
-func (j *JiraAdapter) GetIssueTypeFields(issueTypeName string) (map[string]interface{}, error) {
- // Use the createmeta endpoint to get field information
- url := fmt.Sprintf("%s/rest/api/2/issue/createmeta?projectKeys=%s&expand=projects.issuetypes.fields",
- j.baseURL, j.projectKey)
-
- req, err := http.NewRequest("GET", url, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Authorization", fmt.Sprintf("Basic %s", j.getAuthHeader()))
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := j.client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to execute request: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("failed to get createmeta with status %d: %s", resp.StatusCode, string(body))
- }
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
-
- // Parse the response
- var createMeta map[string]interface{}
- if err := json.Unmarshal(body, &createMeta); err != nil {
- return nil, fmt.Errorf("failed to parse response: %w", err)
- }
-
- result := make(map[string]interface{})
-
- // Navigate through the response structure
- projects, ok := createMeta["projects"].([]interface{})
- if !ok || len(projects) == 0 {
- return nil, fmt.Errorf("no projects found in response")
- }
-
- project := projects[0].(map[string]interface{})
- issueTypes, ok := project["issuetypes"].([]interface{})
- if !ok {
- return nil, fmt.Errorf("no issue types found in project")
- }
-
- // Find the requested issue type
- var targetIssueType map[string]interface{}
- for _, it := range issueTypes {
- issueType := it.(map[string]interface{})
- if name, ok := issueType["name"].(string); ok && name == issueTypeName {
- targetIssueType = issueType
- break
- }
- }
-
- if targetIssueType == nil {
- // List available issue types
- availableTypes := []string{}
- for _, it := range issueTypes {
- if issueType, ok := it.(map[string]interface{}); ok {
- if name, ok := issueType["name"].(string); ok {
- availableTypes = append(availableTypes, name)
- }
- }
- }
- return nil, fmt.Errorf("issue type '%s' not found. Available types: %v", issueTypeName, availableTypes)
- }
-
- // Extract field information
- fields, ok := targetIssueType["fields"].(map[string]interface{})
- if !ok {
- return nil, fmt.Errorf("no fields found for issue type")
- }
-
- // Process fields to extract relevant information
- fieldInfo := []map[string]interface{}{}
- for fieldKey, fieldData := range fields {
- field := fieldData.(map[string]interface{})
-
- info := map[string]interface{}{
- "key": fieldKey,
- }
-
- if name, ok := field["name"].(string); ok {
- info["name"] = name
- }
-
- if required, ok := field["required"].(bool); ok {
- info["required"] = required
- }
-
- if schema, ok := field["schema"].(map[string]interface{}); ok {
- if fieldType, ok := schema["type"].(string); ok {
- info["type"] = fieldType
- }
- if items, ok := schema["items"].(string); ok {
- info["items"] = items
- }
- }
-
- if allowedValues, ok := field["allowedValues"].([]interface{}); ok && len(allowedValues) > 0 {
- values := []string{}
- for _, v := range allowedValues {
- if val, ok := v.(map[string]interface{}); ok {
- if name, ok := val["name"].(string); ok {
- values = append(values, name)
- } else if value, ok := val["value"].(string); ok {
- values = append(values, value)
- }
- }
- }
- if len(values) > 0 {
- info["allowedValues"] = values
- }
- }
-
- fieldInfo = append(fieldInfo, info)
- }
-
- result["issueType"] = issueTypeName
- result["fields"] = fieldInfo
-
- return result, nil
-}
-
-// UpdateTask updates an existing task in Jira
-func (j *JiraAdapter) UpdateTask(task domain.Task) error {
- if task.JiraID == "" {
- return fmt.Errorf("task does not have a Jira ID")
- }
-
- // Build the description with acceptance criteria
- description := task.Description
- if len(task.AcceptanceCriteria) > 0 {
- description += "\n\nh3. Acceptance Criteria\n"
- for _, ac := range task.AcceptanceCriteria {
- description += fmt.Sprintf("* %s\n", ac)
- }
- }
-
- // Create the request payload - only update fields that can change
- payload := map[string]interface{}{
- "fields": map[string]interface{}{
- "summary": task.Title,
- "description": description,
- },
- }
-
- jsonPayload, err := json.Marshal(payload)
- if err != nil {
- return fmt.Errorf("failed to marshal payload: %w", err)
- }
-
- url := fmt.Sprintf("%s/rest/api/2/issue/%s", j.baseURL, task.JiraID)
- req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonPayload))
- if err != nil {
- return fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Authorization", fmt.Sprintf("Basic %s", j.getAuthHeader()))
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := j.client.Do(req)
- if err != nil {
- return fmt.Errorf("failed to execute request: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("failed to update task with status %d: %s", resp.StatusCode, string(body))
- }
-
- return nil
-}
-
-// CreateTicket creates a new ticket in JIRA with dynamic field mapping
-func (j *JiraAdapter) CreateTicket(ticket domain.Ticket) (string, error) {
- // Build the payload dynamically using field mappings
- fields := j.buildFieldsPayload(ticket.CustomFields, ticket.Title, ticket.Description, ticket.AcceptanceCriteria)
-
- payload := map[string]interface{}{
- "fields": fields,
- }
-
- jsonPayload, err := json.Marshal(payload)
- if err != nil {
- return "", fmt.Errorf("failed to marshal payload: %w", err)
- }
-
- // Create issue in Jira
- url := fmt.Sprintf("%s/rest/api/2/issue", j.baseURL)
- req, err := http.NewRequest("POST", url, bytes.NewReader(jsonPayload))
- if err != nil {
- return "", fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Authorization", fmt.Sprintf("Basic %s", j.getAuthHeader()))
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := j.client.Do(req)
- if err != nil {
- return "", fmt.Errorf("failed to execute request: %w", err)
- }
- defer resp.Body.Close()
-
- body, _ := io.ReadAll(resp.Body)
-
- if resp.StatusCode != http.StatusCreated {
- return "", fmt.Errorf("failed to create ticket with status %d: %s", resp.StatusCode, string(body))
- }
-
- // Parse the response to get the issue key
- var result map[string]interface{}
- if err := json.Unmarshal(body, &result); err != nil {
- return "", fmt.Errorf("failed to parse response: %w", err)
- }
-
- key, ok := result["key"].(string)
- if !ok {
- return "", fmt.Errorf("response did not contain issue key")
- }
-
- return key, nil
-}
-
-// UpdateTicket updates an existing ticket in JIRA with dynamic field mapping
-func (j *JiraAdapter) UpdateTicket(ticket domain.Ticket) error {
- if ticket.JiraID == "" {
- return fmt.Errorf("ticket does not have a Jira ID")
- }
-
- // Build the payload dynamically using field mappings
- fields := j.buildFieldsPayload(ticket.CustomFields, ticket.Title, ticket.Description, ticket.AcceptanceCriteria)
-
- // Remove fields that shouldn't be updated
- delete(fields, "project")
- delete(fields, "issuetype")
-
- payload := map[string]interface{}{
- "fields": fields,
- }
-
- jsonPayload, err := json.Marshal(payload)
- if err != nil {
- return fmt.Errorf("failed to marshal payload: %w", err)
- }
-
- // Update issue in Jira
- url := fmt.Sprintf("%s/rest/api/2/issue/%s", j.baseURL, ticket.JiraID)
- req, err := http.NewRequest("PUT", url, bytes.NewReader(jsonPayload))
- if err != nil {
- return fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Authorization", fmt.Sprintf("Basic %s", j.getAuthHeader()))
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := j.client.Do(req)
- if err != nil {
- return fmt.Errorf("failed to execute request: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("failed to update ticket with status %d: %s", resp.StatusCode, string(body))
- }
-
- return nil
-}
-
-// buildFieldsPayload builds the JIRA fields payload using field mappings
-func (j *JiraAdapter) buildFieldsPayload(customFields map[string]string, title, description string, acceptanceCriteria []string) map[string]interface{} {
- fields := make(map[string]interface{})
-
- // Add standard fields
- fields["summary"] = title
-
- // Build description with acceptance criteria
- fullDescription := description
- if len(acceptanceCriteria) > 0 {
- fullDescription += "\n\nh3. Acceptance Criteria\n"
- for _, ac := range acceptanceCriteria {
- fullDescription += fmt.Sprintf("* %s\n", ac)
- }
- }
- fields["description"] = fullDescription
-
- // Set project and issue type from defaults if not in custom fields
- if _, hasProject := customFields["Project"]; !hasProject {
- fields["project"] = map[string]interface{}{
- "key": j.projectKey,
- }
- }
-
- if _, hasType := customFields["Type"]; !hasType {
- fields["issuetype"] = map[string]interface{}{
- "name": j.storyType,
- }
- }
-
- // Map custom fields using field mappings
- for fieldName, fieldValue := range customFields {
- if mappingInfo, exists := j.fieldMappings[fieldName]; exists {
- // Check if mapping is complex (has id and type)
- switch mapping := mappingInfo.(type) {
- case string:
- // Simple mapping - check if it's a known array field
- fieldType := "string"
- if mapping == "labels" || mapping == "components" {
- fieldType = "array"
- }
- fields[mapping] = j.convertFieldValue(fieldValue, fieldType)
- case map[string]interface{}:
- // Complex mapping with type information
- if id, hasID := mapping["id"].(string); hasID {
- fieldType := "string"
- if t, hasType := mapping["type"].(string); hasType {
- fieldType = t
- }
- fields[id] = j.convertFieldValue(fieldValue, fieldType)
- }
- }
- }
- }
-
- return fields
-}
-
-// convertFieldValue converts a field value to the appropriate type for JIRA
-func (j *JiraAdapter) convertFieldValue(value string, fieldType string) interface{} {
- switch fieldType {
- case "number":
- // Try to convert to number
- if value == "" {
- return nil
- }
- // JIRA expects numbers as numbers, not strings
- var num float64
- if _, err := fmt.Sscanf(value, "%f", &num); err == nil {
- return num
- }
- // If conversion fails, return as string
- return value
- case "array":
- // Convert comma-separated values to array
- if value == "" {
- return []string{}
- }
- parts := strings.Split(value, ",")
- result := make([]string, 0, len(parts))
- for _, part := range parts {
- trimmed := strings.TrimSpace(part)
- if trimmed != "" {
- result = append(result, trimmed)
- }
- }
- return result
- default:
- return value
- }
-}
-
-// SearchTickets searches for tickets in Jira using JQL query
-func (j *JiraAdapter) SearchTickets(projectKey string, jql string) ([]domain.Ticket, error) {
- // Construct JQL query - combine project filter with provided JQL
- fullJQL := fmt.Sprintf(`project = "%s"`, projectKey)
- if jql != "" {
- fullJQL = fmt.Sprintf(`%s AND %s`, fullJQL, jql)
- }
-
- // Build fields list based on field mappings
- fields := []string{"key", "summary", "description", "issuetype", "parent"}
- for _, mapping := range j.fieldMappings {
- switch m := mapping.(type) {
- case string:
- if m != "summary" && m != "description" && m != "issuetype" && m != "project" {
- fields = append(fields, m)
- }
- case map[string]interface{}:
- if id, ok := m["id"].(string); ok {
- fields = append(fields, id)
- }
- }
- }
-
- // Prepare request payload
- payload := map[string]interface{}{
- "jql": fullJQL,
- "fields": fields,
- "maxResults": 100, // TODO: Add pagination support
- }
-
- jsonPayload, err := json.Marshal(payload)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal search payload: %w", err)
- }
-
- // Execute search request
- url := fmt.Sprintf("%s/rest/api/2/search", j.baseURL)
- req, err := http.NewRequest("POST", url, bytes.NewReader(jsonPayload))
- if err != nil {
- return nil, fmt.Errorf("failed to create search request: %w", err)
- }
-
- req.Header.Set("Authorization", fmt.Sprintf("Basic %s", j.getAuthHeader()))
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := j.client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to execute search request: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read search response: %w", err)
- }
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("search failed with status %d: %s", resp.StatusCode, string(body))
- }
-
- // Parse search response
- var searchResult map[string]interface{}
- if err := json.Unmarshal(body, &searchResult); err != nil {
- return nil, fmt.Errorf("failed to parse search response: %w", err)
- }
-
- issues, ok := searchResult["issues"].([]interface{})
- if !ok {
- return nil, fmt.Errorf("search response missing issues array")
- }
-
- // Convert Jira issues to domain tickets
- tickets := make([]domain.Ticket, 0, len(issues))
- for _, issue := range issues {
- issueMap, ok := issue.(map[string]interface{})
- if !ok {
- continue
- }
-
- ticket := j.parseJiraIssue(issueMap)
- tickets = append(tickets, ticket)
- }
-
- return tickets, nil
-}
-
-// parseJiraIssue converts a JIRA issue JSON response to a domain.Ticket
-func (j *JiraAdapter) parseJiraIssue(issue map[string]interface{}) domain.Ticket {
- ticket := domain.Ticket{
- CustomFields: make(map[string]string),
- }
-
- // Get issue key
- if key, ok := issue["key"].(string); ok {
- ticket.JiraID = key
- }
-
- // Parse fields
- fields, ok := issue["fields"].(map[string]interface{})
- if !ok {
- return ticket
- }
-
- // Get standard fields
- if summary, ok := fields["summary"].(string); ok {
- ticket.Title = summary
- }
-
- if description, ok := fields["description"].(string); ok {
- // Extract description without acceptance criteria
- parts := strings.Split(description, "h3. Acceptance Criteria")
- ticket.Description = strings.TrimSpace(parts[0])
-
- // Parse acceptance criteria if present
- if len(parts) > 1 {
- acLines := strings.Split(parts[1], "\n")
- for _, line := range acLines {
- line = strings.TrimSpace(line)
- if strings.HasPrefix(line, "* ") {
- ticket.AcceptanceCriteria = append(ticket.AcceptanceCriteria, strings.TrimPrefix(line, "* "))
- }
- }
- }
- }
-
- // Get issue type
- if issueType, ok := fields["issuetype"].(map[string]interface{}); ok {
- if typeName, ok := issueType["name"].(string); ok {
- ticket.CustomFields["Type"] = typeName
- }
- }
-
- // Get parent if it's a subtask
- if parent, ok := fields["parent"].(map[string]interface{}); ok {
- if parentKey, ok := parent["key"].(string); ok {
- ticket.CustomFields["Parent"] = parentKey
- }
- }
-
- // Map JIRA fields back to human-readable names using reverse mapping
- reverseMapping := j.createReverseFieldMapping()
- for jiraField, jiraValue := range fields {
- if humanName, exists := reverseMapping[jiraField]; exists {
- // Convert JIRA value to string representation
- switch v := jiraValue.(type) {
- case string:
- ticket.CustomFields[humanName] = v
- case float64:
- ticket.CustomFields[humanName] = fmt.Sprintf("%g", v)
- case []interface{}:
- // Handle arrays (e.g., labels, components)
- values := make([]string, 0, len(v))
- for _, item := range v {
- if str, ok := item.(string); ok {
- values = append(values, str)
- } else if obj, ok := item.(map[string]interface{}); ok {
- if name, ok := obj["name"].(string); ok {
- values = append(values, name)
- }
- }
- }
- if len(values) > 0 {
- ticket.CustomFields[humanName] = strings.Join(values, ", ")
- }
- case map[string]interface{}:
- // Handle objects (e.g., assignee, reporter, priority)
- if name, ok := v["name"].(string); ok {
- ticket.CustomFields[humanName] = name
- } else if displayName, ok := v["displayName"].(string); ok {
- ticket.CustomFields[humanName] = displayName
- }
- }
- }
- }
-
- return ticket
-}
-
-// createReverseFieldMapping creates a reverse mapping from JIRA field IDs to human-readable names
-func (j *JiraAdapter) createReverseFieldMapping() map[string]string {
- reverse := make(map[string]string)
-
- for humanName, mapping := range j.fieldMappings {
- switch m := mapping.(type) {
- case string:
- reverse[m] = humanName
- case map[string]interface{}:
- if id, ok := m["id"].(string); ok {
- reverse[id] = humanName
- }
- }
- }
-
- return reverse
-}
-
-
-
-# Ticketr 🎫
-
-A powerful command-line tool that bridges the gap between local Markdown files and Jira, enabling seamless story and task management with bidirectional synchronization.
-
-[](https://go.dev)
-[](LICENSE)
-[](Dockerfile)
-
-## ✨ Features
-
-- **📝 Markdown-First Workflow**: Define stories and tasks in simple Markdown files
-- **🔄 Bidirectional Sync**: Create new items or update existing ones in Jira
-- **🎯 Smart Updates**: Automatically detects and updates only changed items
-- **🚀 CI/CD Ready**: Built for automation with non-interactive modes
-- **🐳 Docker Support**: Lightweight container (~15MB) for consistent execution
-- **🔒 Secure**: Environment-based configuration keeps credentials safe
-
-## 🚀 Quick Start
-
-### Installation
-
-#### Using Go
-```bash
-go install github.com/karolswdev/ticketr/cmd/ticketr@latest
-```
-
-#### Building from Source
-```bash
-git clone https://github.com/karolswdev/ticketr.git
-cd ticketr
-go build -o ticketr cmd/ticketr/main.go
-```
-
-### Configuration
-
-Set up your Jira credentials as environment variables:
-
-```bash
-export JIRA_URL="https://yourcompany.atlassian.net"
-export JIRA_EMAIL="your.email@company.com"
-export JIRA_API_KEY="your-api-token"
-export JIRA_PROJECT_KEY="PROJ"
-```
-
-💡 **Tip**: Store these in a `.env` file for convenience (see `.env.example`)
-
-### Basic Usage
-
-1. **Create a story file** (`stories.md`):
-
-```markdown
-# STORY: User Authentication System
-
-## Description
-As a developer, I want to implement a secure authentication system
-so that users can safely access the application.
-
-## Acceptance Criteria
-- Users can register with email and password
-- Passwords are securely hashed
-- Session management is implemented
-
-## Tasks
-- Set up authentication database schema
-- Implement password hashing service
-- Create login/logout endpoints
-- Add session middleware
-```
-
-2. **Sync with Jira**:
-
-```bash
-ticketr -f stories.md
-```
-
-3. **Result**: Your file is updated with Jira IDs:
-
-```markdown
-# STORY: [PROJ-123] User Authentication System
-
-## Description
-As a developer, I want to implement a secure authentication system
-so that users can safely access the application.
-
-## Acceptance Criteria
-- Users can register with email and password
-- Passwords are securely hashed
-- Session management is implemented
-
-## Tasks
-- [PROJ-124] Set up authentication database schema
-- [PROJ-125] Implement password hashing service
-- [PROJ-126] Create login/logout endpoints
-- [PROJ-127] Add session middleware
-```
-
-## 📖 Advanced Usage
-
-### Updating Existing Items
-
-Simply edit your file and run the tool again - it intelligently handles updates:
-
-```markdown
-# STORY: [PROJ-123] User Authentication System (Updated)
-
-## Tasks
-- [PROJ-124] Set up authentication database schema ✅
-- [PROJ-125] Implement password hashing service
-- Add JWT token generation # New task will be created
-```
-
-### Command-Line Options
-
-```bash
-# Push tickets to JIRA (with pre-flight validation)
-ticketr push stories.md
-
-# Pull tickets from JIRA to Markdown
-ticketr pull --project PROJ --jql "status=Done" -o done_tickets.md
-
-# Pull tickets from a specific epic
-ticketr pull --epic PROJ-100 -o epic_tickets.md
-
-# Verbose output for debugging
-ticketr push stories.md --verbose
-
-# Continue on errors (CI/CD mode)
-ticketr push stories.md --force-partial-upload
-
-# Discover JIRA schema and generate configuration
-ticketr schema > .ticketr.yaml
-
-# Legacy mode (backward compatibility)
-ticketr -f stories.md -v --force-partial-upload
-```
-
-### Push Command
-
-**Note**: Ticketr validates your file for correctness before sending any data to Jira, preventing partial failures. Validation includes:
-- Hierarchical rules (e.g., Sub-tasks cannot be children of Epics)
-- Required fields validation
-- Format validation (only `# TICKET:` format is supported, legacy `# STORY:` format is rejected)
-
-### Pull Command
-
-The `ticketr pull` command fetches tickets from JIRA and intelligently merges them with your local file:
-
-```bash
-# Pull all tickets from a project
-ticketr pull --project PROJ
-
-# Pull tickets using JQL query
-ticketr pull --jql "status IN ('In Progress', 'Done')"
-
-# Pull tickets from a specific epic
-ticketr pull --epic PROJ-100 --output sprint_23.md
-
-# Combine filters
-ticketr pull --project PROJ --jql "assignee=currentUser()" -o my_tickets.md
-```
-
-**Pull Command Options:**
-- `--project` - JIRA project key to pull from (uses JIRA_PROJECT_KEY env var if not specified)
-- `--epic` - Filter tickets by epic key
-- `--jql` - Custom JQL query for filtering
-- `-o, --output` - Output file path (default: pulled_tickets.md)
-
-**Conflict Detection:**
-
-The pull command now features intelligent conflict detection:
-- **Safe Merge**: Automatically updates tickets that have only changed remotely
-- **Conflict Detection**: Identifies when both local and remote versions have changed
-- **Local Preservation**: Keeps local changes when only local has been modified
-- **State Tracking**: Uses `.ticketr.state` to track both local and remote changes
-
-When conflicts are detected, you'll see:
-```
-⚠️ Conflict detected! The following tickets have both local and remote changes:
- - TICKET-123
- - TICKET-456
-
-To force overwrite local changes with remote changes, use --force flag
-```
-
-### Schema Discovery
-
-The `ticketr schema` command helps you discover available fields in your JIRA instance and generate a proper configuration file:
-
-```bash
-# Discover fields and generate configuration
-ticketr schema > .ticketr.yaml
-
-# View available fields with verbose output
-ticketr schema -v
-
-# The command will output field mappings like:
-# field_mappings:
-# "Story Points":
-# id: "customfield_10010"
-# type: "number"
-# "Sprint": "customfield_10020"
-# "Epic Link": "customfield_10014"
-```
-
-This is especially useful when working with custom fields that vary between JIRA instances.
-
-### State Management
-
-Ticketr automatically tracks changes to prevent redundant updates to JIRA:
-
-```bash
-# The .ticketr.state file is created automatically
-# It stores SHA256 hashes of ticket content
-
-# Only changed tickets are pushed to JIRA
-ticketr push stories.md # Skips unchanged tickets
-
-# The state file contains:
-# - Ticket ID to content hash mappings
-# - Automatically updated after each successful push
-```
-
-**Note**: The `.ticketr.state` file should be added to `.gitignore` as it's environment-specific.
-
-### Docker Usage
-
-Build and run using Docker:
-
-```bash
-# Build the Docker image
-docker build -t ticketr .
-
-# Run with Docker
-docker run --rm \
- -e JIRA_URL="$JIRA_URL" \
- -e JIRA_EMAIL="$JIRA_EMAIL" \
- -e JIRA_API_KEY="$JIRA_API_KEY" \
- -e JIRA_PROJECT_KEY="$JIRA_PROJECT_KEY" \
- -v $(pwd)/stories.md:/data/stories.md \
- ticketr -f /data/stories.md
-
-# Or use Docker Compose (reads .env automatically)
-docker-compose run --rm ticketr
-```
-
-## 📋 Story Templates
-
-### Epic Template
-```markdown
-# STORY: [Epic] Cloud Migration Initiative
-
-## Description
-Migrate all services to cloud infrastructure for improved scalability and reliability.
-
-## Acceptance Criteria
-- All services running in cloud
-- Zero data loss during migration
-- Downtime < 1 hour
-
-## Tasks
-- Audit current infrastructure
-- Design cloud architecture
-- Set up cloud environments
-- Migrate databases
-- Migrate services
-- Update DNS and routing
-```
-
-### Bug Report Template
-```markdown
-# STORY: [Bug] Login fails with special characters
-
-## Description
-Users cannot login when password contains special characters like & or %.
-
-## Acceptance Criteria
-- All special characters work in passwords
-- Existing users can still login
-- No security vulnerabilities introduced
-
-## Tasks
-- Reproduce the issue
-- Fix password encoding
-- Add comprehensive tests
-- Update documentation
-```
-
-### Feature Template
-```markdown
-# STORY: Dark Mode Support
-
-## Description
-As a user, I want to switch between light and dark themes
-so that I can use the app comfortably in different lighting conditions.
-
-## Acceptance Criteria
-- Toggle switch in settings
-- Theme preference persisted
-- All UI elements properly themed
-
-## Tasks
-- Design dark color palette
-- Implement theme context
-- Update all components
-- Add theme toggle to settings
-- Test across all pages
-```
-
-## 🔄 Workflow Examples
-
-### Sprint Planning Workflow
-
-1. **Create sprint backlog** in Markdown:
-```bash
-vim sprint-23.md # Define all stories for the sprint
-```
-
-2. **Review with team** (stories still in Markdown)
-
-3. **Push to Jira** when approved:
-```bash
-ticketr -f sprint-23.md
-```
-
-4. **Track progress** by updating the file:
-```markdown
-## Tasks
-- [PROJ-124] Database setup ✅ DONE
-- [PROJ-125] API implementation 🚧 IN PROGRESS
-- [PROJ-126] Frontend integration
-```
-
-### CI/CD Integration
-
-```yaml
-# .github/workflows/jira-sync.yml
-name: Sync Stories to Jira
-on:
- push:
- paths:
- - 'stories/*.md'
-
-jobs:
- sync:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v2
-
- - name: Sync to Jira
- run: |
- docker run --rm \
- -e JIRA_URL=${{ secrets.JIRA_URL }} \
- -e JIRA_EMAIL=${{ secrets.JIRA_EMAIL }} \
- -e JIRA_API_KEY=${{ secrets.JIRA_API_KEY }} \
- -e JIRA_PROJECT_KEY=${{ secrets.JIRA_PROJECT_KEY }} \
- -v ${{ github.workspace }}:/data \
- ticketr \
- -f /data/stories/backlog.md \
- --force-partial-upload
-```
-
-## 🏗️ Architecture
-
-Ticketr follows a clean architecture pattern:
-
-```
-ticketr/
-├── cmd/ticketr/ # CLI entry point
-├── internal/
-│ ├── core/ # Business logic
-│ │ ├── domain/ # Domain models
-│ │ ├── ports/ # Interface definitions
-│ │ └── services/ # Core services
-│ └── adapters/ # External integrations
-│ ├── cli/ # Command-line interface
-│ ├── filesystem/ # File I/O operations
-│ └── jira/ # Jira API client
-```
-
-## 🤝 Contributing
-
-We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
-
-### Development Setup
-
-```bash
-# Clone the repository
-git clone https://github.com/karolswdev/ticketr.git
-cd ticketr
-
-# Install dependencies
-go mod download
-
-# Run tests
-go test ./...
-
-# Build
-go build -o ticketr cmd/ticketr/main.go
-```
-
-## 📄 License
-
-This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
-
-## 🆘 Support
-
-- 📖 [Documentation](https://github.com/karolswdev/ticketr/wiki)
-- 🐛 [Issue Tracker](https://github.com/karolswdev/ticketr/issues)
-- 💬 [Discussions](https://github.com/karolswdev/ticketr/discussions)
-
-## 🙏 Acknowledgments
-
-Built with ❤️ using:
-- [Go](https://golang.org/) - The programming language
-- [Jira REST API](https://developer.atlassian.com/cloud/jira/platform/rest/v2/) - Atlassian's API
-- [Alpine Linux](https://alpinelinux.org/) - Container base image
-
----
-
-**Happy Planning!** 🚀
-
-
-