diff --git a/.spec/002-jira-integration/.spec-status.json b/.spec/002-jira-integration/.spec-status.json new file mode 100644 index 0000000..3a02b7a --- /dev/null +++ b/.spec/002-jira-integration/.spec-status.json @@ -0,0 +1,3 @@ +{ + "current-step": "Implementation Complete" +} \ No newline at end of file diff --git a/.spec/002-jira-integration/api-integration-spec.yaml b/.spec/002-jira-integration/api-integration-spec.yaml new file mode 100644 index 0000000..42ad8c1 --- /dev/null +++ b/.spec/002-jira-integration/api-integration-spec.yaml @@ -0,0 +1,95 @@ +title: Jira REST API Integration Specification +version: 1.0.0 +description: Technical specification for Jira REST API integration in specware CLI + +endpoint: + method: GET + path: "/rest/api/2/issue/{issueKey}" + base_url: "${JIRA_URL}" + timeout: 30s + +authentication: + type: personal_access_token + header: "Authorization" + format: "Bearer ${JIRA_API_TOKEN}" + +request: + headers: + Authorization: "Bearer ${JIRA_API_TOKEN}" + Accept: "application/json" + Content-Type: "application/json" + parameters: + issueKey: + type: string + pattern: "^[A-Za-z]+-[0-9]+$" + description: "Jira issue key format (e.g., PROJ-123 or proj-123)" + +response: + success: + status_code: 200 + content_type: "application/json" + required_fields: + - key + - fields.summary + - fields.description + - fields.status.name + - fields.issuetype.name + - fields.priority.name + - fields.assignee.displayName + optional_fields: + - fields.assignee.emailAddress + - fields.reporter.displayName + - fields.created + - fields.updated + +error_responses: + authentication_failure: + status_code: 401 + message: "Authentication failed. Check JIRA_API_TOKEN environment variable." + user_action: "Verify your personal access token has the correct permissions." + + issue_not_found: + status_code: 404 + message: "Issue '{issueKey}' not found." + user_action: "Verify the issue key exists and you have permission to view it." + + permission_denied: + status_code: 403 + message: "Access denied to issue '{issueKey}'." + user_action: "Verify you have permission to view this issue." + + invalid_url: + status_code: null + message: "Invalid JIRA_URL format." + user_action: "Ensure JIRA_URL is a valid URL (e.g., https://company.atlassian.net)" + + network_timeout: + status_code: null + message: "Network timeout after 30 seconds." + user_action: "Check network connectivity and Jira instance availability." + + server_error: + status_code: 500 + message: "Jira server error." + user_action: "Try again later or contact your Jira administrator." + +environment_variables: + JIRA_URL: + required: true + validation: non_empty_string + example: "https://company.atlassian.net" + + JIRA_API_TOKEN: + required: true + validation: non_empty_string + sensitive: true + description: "Personal access token with issue read permissions" + +validation: + startup_checks: + - environment_variable_presence + - environment_variable_non_empty + + runtime_checks: + - issue_key_format + - url_accessibility \ No newline at end of file diff --git a/.spec/002-jira-integration/context-implementation-plan.md b/.spec/002-jira-integration/context-implementation-plan.md new file mode 100644 index 0000000..a8ab210 --- /dev/null +++ b/.spec/002-jira-integration/context-implementation-plan.md @@ -0,0 +1,70 @@ +# Context: Implementation Plan + +## Codebase Architecture Analysis + +### Project Structure +- **Root structure**: Go module with Cobra CLI framework +- **Command layer**: `cmd/` package with root.go registering subcommands +- **Business logic**: `internal/spec/` package containing core functionality +- **Assets**: `assets/` package with embedded filesystem for templates, commands, agents +- **Testing**: Ginkgo/Gomega BDD framework in `internal/spec/*_test.go` + +### Command Implementation Patterns +- **Command definition**: Cobra commands in `cmd/` files with Use, Short, Long, Args, Run +- **Function delegation**: Commands call `internal/spec` functions with error handling +- **Error handling**: Commands print errors and call `os.Exit(1)` on failure +- **File operations**: Functions return `([]string, error)` where strings are created file paths +- **Status updates**: All commands follow pattern of status output then file listing + +### Testing Patterns +- **Framework**: Ginkgo v2 with Gomega assertions in `spec_test` package +- **Setup**: `BeforeEach`/`AfterEach` with temp directory creation/cleanup +- **Tests**: Describe/It structure testing directory creation and file existence +- **Assertions**: `Expect(err).NotTo(HaveOccurred())` and `Expect(path).To(BeADirectory())` + +### Existing Subcommand Structure +- **Root command**: `specware` in `cmd/root.go:22-25` adds: initCmd, localizeTemplatesCmd, featureCmd +- **Feature subcommands**: `cmd/feature.go:118-122` adds: newRequirementsCmd, newImplementationPlanCmd, updateStateCmd +- **Pattern**: Each command calls internal/spec function, prints results, handles errors + +### Dependencies and Build +- **Core deps**: cobra CLI, ginkgo/gomega testing, embed FS +- **No HTTP libs**: Currently no HTTP client dependencies +- **Build**: Simple `go build -o specware .` and `go test ./...` commands +- **Module**: Go 1.24.5 with standard semantic versioning + +### Code Style Observations +- **Error messages**: Descriptive with context using fmt.Errorf +- **Validation**: Input validation functions (ValidateFeatureName) with regex patterns +- **File handling**: Standard os package operations with 0644/0755 permissions +- **JSON**: Standard encoding/json for .spec-status.json files +- **Package organization**: Clear separation between CLI layer and business logic + +## Questions & Answers +Questions and provided answers + +### Q1: Should the new Jira command be added to the root command or as a subcommand under 'feature'? +**Answer:** It should be added under a new sub-command `jira` like `specware jira get-issue`. This ensures scalability and seperates it from the feature commands and the core "project setup" commands at the root level. + +### Q2: Should we create a new file `cmd/jira.go` following the existing pattern? +**Answer:** y + +### Q3: Should the HTTP client implementation be in the internal/spec package or a new internal/jira package? +**Answer:** New internal jira package + +### Q4: Should we use Go's standard net/http package or add an external HTTP client dependency? +**Answer:** You should review whether there is an official, well-documented, and often used golang SDK for jira. If that exists, we should prefer to use it. If it does not exist, standard packages are preferred. + +### Q5: Should the output formatting logic be a separate function in internal/spec package? +**Answer:** It should be a seperate function in the internal/jira package + +### Testing Questions + +### Q6: Should we write unit tests for the HTTP client and formatting functions separately? +**Answer:** Yes + +### Q7: Should we create integration tests that make actual HTTP calls to a test Jira instance? +**Answer:** No, we should create integration tests that make calls to a mock jira instance. + +## Context Gathering Results +Collection of any additional detail needed to inform requirements. diff --git a/.spec/002-jira-integration/context-requirements.md b/.spec/002-jira-integration/context-requirements.md new file mode 100644 index 0000000..e448cb3 --- /dev/null +++ b/.spec/002-jira-integration/context-requirements.md @@ -0,0 +1,70 @@ +# Context: Requirements + +## Questions & Answers +Questions and provided answers + +### Q1: Will the Jira integration support fetching issues from multiple Jira instances? +**Answer:** No + +### Q2: Should the Jira integration cache issue data locally to improve performance? +**Answer:** No + +### Q3: Will users need to authenticate interactively or only use pre-configured tokens? +**Answer:** Pre-configured + +### Q4: Should the integration support fetching related issues (linked issues, sub-tasks)? +**Answer:** No + +### Q5: Will the Jira data need to be transformed or filtered before use in specifications? +**Answer:** Yes, output should be formatted in a way Claude Code can understand - but the content should be unmodified. + +## Context Gathering Results +Collection of any additional detail needed to inform requirements. + +### Codebase Architecture Analysis + +**Command Structure:** +- Uses Cobra CLI framework (github.com/spf13/cobra) +- Commands defined in `cmd/` package: root.go, feature.go, init.go, localize_templates.go +- Root command structure in `cmd/root.go:22-25` adds subcommands: initCmd, localizeTemplatesCmd, featureCmd + +**Feature Command Pattern:** +- Feature commands in `cmd/feature.go` follow pattern: featureCmd.AddCommand() in init() +- Existing subcommands: new-requirements, new-implementation-plan, update-state +- All commands use `spec` package functions: `spec.CreateNewRequirements()`, `spec.UpdateFeatureStatus()` + +**Internal Package Structure:** +- Core logic in `internal/spec/spec.go` +- Functions follow pattern: validate input, handle filesystem operations, return created files list +- Error handling: return error with context using fmt.Errorf() +- File creation: uses os.WriteFile(), os.MkdirAll() with 0644/0755 permissions + +**No HTTP Client Patterns Found:** +- Current codebase has no HTTP clients or external service integrations +- No existing patterns for API authentication or JSON parsing for external services +- Will need to add new dependencies for HTTP operations + +**Dependencies:** +- Current: cobra CLI, ginkgo/gomega testing, embedded file system +- Missing for Jira integration: HTTP client library, JSON unmarshaling for Jira responses + +**Testing Framework:** +- Uses Ginkgo v2 and Gomega for BDD-style testing +- Test files: `internal/spec/spec_test.go`, `internal/spec/spec_suite_test.go` + +### Expert Questions + +### Q6: Should the Jira integration validate environment variables at command startup? +**Answer:** Yes, validation should be limited to the fact that they are a non-empty string and nothing more. Do not add complex validation. + +### Q7: Should the Jira issue data be output as structured JSON or formatted text? +**Answer:** No + +### Q8: Should the integration support custom field extraction from Jira issues? +**Answer:** No, avoid complexity we aren't certain we need. + +### Q9: Should network timeouts and retry logic be configurable? +**Answer:** No + +### Q10: Should the command support outputting multiple issues in a single call? +**Answer:** No diff --git a/.spec/002-jira-integration/implementation-plan.md b/.spec/002-jira-integration/implementation-plan.md new file mode 100644 index 0000000..15b485d --- /dev/null +++ b/.spec/002-jira-integration/implementation-plan.md @@ -0,0 +1,204 @@ +# Implementation Plan: Jira Integration for Specification Building + +## Technical Approach + +This implementation adds Jira integration capabilities to the specware CLI tool by creating a new `jira` subcommand with `get-issue` functionality. The implementation follows existing patterns: + +- **New command structure**: `cmd/jira.go` following the pattern of `cmd/feature.go` +- **New business logic package**: `internal/jira/` for HTTP client and formatting logic +- **Cobra integration**: Add `jiraCmd` to root command registration +- **Environment-based configuration**: Use `JIRA_URL` and `JIRA_API_TOKEN` variables +- **Standard library preference**: Evaluate Go Jira SDK vs net/http package +- **Comprehensive testing**: Unit tests with mocked HTTP server for integration testing + +The feature integrates seamlessly with existing architecture while maintaining separation of concerns through the new `internal/jira` package. + +## Implementation + +### Phase 1: Dependency Research and Project Setup +- [ ] Step 1: Research available Go Jira SDKs (andygrunwald/go-jira, trivago/tgo, others) +- [ ] Step 2: Evaluate SDK documentation, maintenance status, and feature fit +- [ ] Step 3: Make dependency decision (SDK vs standard library) +- [ ] Step 4: Update go.mod with selected dependency if needed +- [ ] Step 5: Run `go mod tidy` to clean dependencies +- [ ] Step 6: Run tests to ensure existing functionality unaffected: `make test` + +### Phase 2: Create Internal Jira Package Structure +- [ ] Step 7: Create `internal/jira/` directory +- [ ] Step 8: Create `internal/jira/client.go` for HTTP client functionality +```go +package jira + +import ( + "context" + "fmt" + "net/http" + "time" +) + +type Client struct { + BaseURL string + APIToken string + HTTPClient *http.Client +} + +func NewClient(baseURL, apiToken string) *Client { + return &Client{ + BaseURL: baseURL, + APIToken: apiToken, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *Client) GetIssue(issueKey string) (*Issue, error) { + // Implementation +} +``` +- [ ] Step 9: Create `internal/jira/types.go` for Jira response structures +- [ ] Step 10: Create `internal/jira/formatter.go` for output formatting +- [ ] Step 11: Run tests to ensure package compiles: `go build ./internal/jira` + +### Phase 3: Implement Core Jira Client Functionality +- [ ] Step 12: Implement environment variable validation in `client.go` +```go +func ValidateEnvironment() error { + if os.Getenv("JIRA_URL") == "" { + return fmt.Errorf("JIRA_URL environment variable is required") + } + if os.Getenv("JIRA_API_TOKEN") == "" { + return fmt.Errorf("JIRA_API_TOKEN environment variable is required") + } + return nil +} +``` +- [ ] Step 13: Implement HTTP request building with authentication +- [ ] Step 14: Implement JSON response parsing according to api-integration-spec.yaml +- [ ] Step 15: Implement error handling for all HTTP status codes per specification +- [ ] Step 16: Test HTTP client with mock server: `go test ./internal/jira` + +### Phase 4: Implement Output Formatting +- [ ] Step 17: Implement `FormatIssue()` function following output-format-spec.txt +```go +func FormatIssue(issue *Issue) string { + var output strings.Builder + output.WriteString(fmt.Sprintf("Issue: %s\n", issue.Key)) + output.WriteString(fmt.Sprintf("Title: %s\n", getFieldOrDefault(issue.Fields.Summary, "No title provided"))) + // Continue per specification... + return output.String() +} +``` +- [ ] Step 18: Implement helper functions for null/empty field handling +- [ ] Step 19: Test formatting with various issue scenarios +- [ ] Step 20: Run formatting tests: `go test ./internal/jira -run TestFormatIssue` + +### Phase 5: Create Jira Command Structure +- [ ] Step 21: Create `cmd/jira.go` following existing Cobra patterns +```go +package cmd + +var jiraCmd = &cobra.Command{ + Use: "jira", + Short: "Jira integration commands", +} + +var getIssueCmd = &cobra.Command{ + Use: "get-issue ", + Short: "Fetch a single Jira issue", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // Implementation calling internal/jira + }, +} +``` +- [ ] Step 22: Implement command validation and error handling +- [ ] Step 23: Add jiraCmd to root command in `cmd/root.go` +- [ ] Step 24: Test command registration: `go run . jira --help` + +### Phase 6: Integrate Command with Jira Client +- [ ] Step 25: Wire command to call `internal/jira` functions +- [ ] Step 26: Implement environment variable validation at command startup +- [ ] Step 27: Implement issue key format validation per specification +- [ ] Step 28: Add proper error messages following existing patterns +- [ ] Step 29: Test end-to-end functionality: `go run . jira get-issue TEST-123` + +### Phase 7: Comprehensive Testing Implementation +- [ ] Step 30: Create `internal/jira/client_test.go` with unit tests +- [ ] Step 31: Create mock HTTP server for integration testing +- [ ] Step 32: Test all error scenarios from api-integration-spec.yaml +- [ ] Step 33: Create `internal/jira/formatter_test.go` with output format tests +- [ ] Step 34: Test various field combinations and null values +- [ ] Step 35: Run full test suite: `make test` + +### Phase 8: Final Integration and Validation +- [ ] Step 36: Build final binary: `make build` +- [ ] Step 37: Test with real Jira instance if available +- [ ] Step 38: Validate output format matches specification exactly +- [ ] Step 39: Test all error scenarios manually +- [ ] Step 40: Run final test suite to ensure no regressions: `make test` + +## Summary of Changes in Key Technical Areas + +### Components to Modify/Create + +**New Components:** +- `cmd/jira.go` - Cobra command definition and CLI interface +- `internal/jira/client.go` - HTTP client and API communication +- `internal/jira/types.go` - Go structs for Jira API responses +- `internal/jira/formatter.go` - Issue output formatting logic +- `internal/jira/client_test.go` - Unit and integration tests for client +- `internal/jira/formatter_test.go` - Unit tests for formatting logic + +**Modified Components:** +- `cmd/root.go` - Add jiraCmd to command registration +- `go.mod` - Add Jira SDK dependency if selected over standard library + +### Database Changes +No database changes required - this is a CLI tool integration. + +### API Changes +This feature creates a new CLI command but does not modify existing APIs. It consumes the Jira REST API `/rest/api/2/issue/{issueKey}` endpoint. + +### User Interface Changes +Adds new CLI command structure: +``` +specware jira get-issue +``` + +## Testing Strategy + +### Unit Tests +- **Client functions**: Test HTTP request building, response parsing, error handling +- **Formatting functions**: Test output format with various input scenarios +- **Environment validation**: Test missing/empty environment variable handling +- **Issue key validation**: Test case-insensitive pattern matching + +### Integration Tests +- **Mock HTTP server**: Test full HTTP request/response cycle with realistic Jira API responses +- **Error scenarios**: Test network timeouts, authentication failures, malformed responses +- **End-to-end command testing**: Test CLI command with mocked backend + +### Test Execution +- Unit tests: `go test ./internal/jira` +- Full test suite: `make test` +- Manual testing: `make build && ./specware jira get-issue PROJ-123` + +## Deployment Considerations + +### Environment Configuration +Users must set environment variables: +```bash +export JIRA_URL="https://company.atlassian.net" +export JIRA_API_TOKEN="your-personal-access-token" +``` + +### Build and Distribution +- Standard Go build process: `make build` +- Binary distribution: Single `specware` executable +- No additional runtime dependencies beyond the binary + +### Backward Compatibility +- No breaking changes to existing commands +- New functionality is additive only +- Existing users unaffected by this feature diff --git a/.spec/002-jira-integration/output-format-spec.txt b/.spec/002-jira-integration/output-format-spec.txt new file mode 100644 index 0000000..0a1b8b9 --- /dev/null +++ b/.spec/002-jira-integration/output-format-spec.txt @@ -0,0 +1,89 @@ +JIRA ISSUE OUTPUT FORMAT SPECIFICATION +====================================== + +Template Structure: +================== + +Issue: {key} +Title: {summary} +Type: {issuetype} +Status: {status} +Priority: {priority} +Assignee: {assignee} + +Description: +{description} + +Field Formatting Rules: +====================== + +1. Header Line: + - Always starts with "Issue: " followed by the issue key + - No additional formatting or prefixes + +2. Field Labels: + - Use exact labels: "Title:", "Type:", "Status:", "Priority:", "Assignee:" + - Each label followed by single space and field value + - One field per line + +3. Field Values: + - Display actual field values with no modification + - If field is null/empty, display "Not assigned" for assignee, "None" for others + - No quotes, brackets, or additional formatting around values + +4. Description Section: + - Always include "Description:" header with blank line after + - Preserve original description formatting including newlines + - If description is empty, display "No description provided" + +5. Line Spacing: + - Single blank line between field section and description header + - No trailing blank lines at end of output + +6. Special Characters: + - No emojis anywhere in output + - Preserve original text formatting (bold, italics) as plain text + - No HTML markup conversion + +Example Output: +============== + +Issue: PROJ-123 +Title: Fix authentication bug in login system +Type: Bug +Status: In Progress +Priority: High +Assignee: John Smith + +Description: +Users are experiencing authentication failures when logging in with +special characters in their passwords. The issue appears to be related +to URL encoding of the credentials. + +Steps to reproduce: +1. Create user with password containing & or + symbols +2. Attempt to log in +3. Authentication fails with 401 error + +Expected: Login should succeed +Actual: 401 Unauthorized error + +Error Handling: +============== + +1. Missing Fields: + - If summary is missing: display "No title provided" + - If type is missing: display "Unknown" + - If status is missing: display "Unknown" + - If priority is missing: display "None" + - If assignee is missing: display "Not assigned" + +2. Long Content: + - No truncation of any field values + - Preserve all content exactly as received from Jira + - No line wrapping modifications + +3. Null/Empty Values: + - Treat null and empty string identically + - Use default values as specified above + - Never display "null" or empty lines \ No newline at end of file diff --git a/.spec/002-jira-integration/requirements.md b/.spec/002-jira-integration/requirements.md new file mode 100644 index 0000000..b528260 --- /dev/null +++ b/.spec/002-jira-integration/requirements.md @@ -0,0 +1,110 @@ +# Requirements Specification: Jira Integration for Specification Building + +## Problem Statement +Currently, the specware tool requires manual input for feature requirements gathering. When feature specifications are based on existing Jira issues, users must manually copy and format Jira issue content into the specification workflow. This creates friction and potential for errors when translating Jira issue details into specification requirements. + +## Solution Overview +Add a Jira integration subcommand to the specware CLI tool that fetches issue data from Jira and outputs it in a format suitable for Claude Code consumption during specification building. This enables seamless integration of existing Jira issue context into the spec-driven development workflow. + +## Functional Requirements + +### Core Functionality +- Add `specware jira get-issue ` subcommand to fetch a single Jira issue +- Read Jira URL and API token from environment variables `JIRA_URL` and `JIRA_API_TOKEN` +- Fetch issue data using Jira REST API `/rest/api/2/issue/{issueKey}` endpoint +- Output formatted text containing issue summary, description, status, and other standard fields +- Support authentication via personal access token only (no interactive authentication) +- Accept case-insensitive issue keys (minimal validation only) + +### Environment Variable Handling +- Validate that `JIRA_URL` and `JIRA_API_TOKEN` environment variables exist and are non-empty strings +- Fail fast with clear error messages if environment variables are missing or empty +- Do not perform complex validation beyond non-empty string checks + +### Output Format +- Format issue data as human-readable text (not JSON) +- Include standard Jira fields: key, summary, description, status, issue type, priority, assignee +- Preserve original issue content without modification +- Format output for optimal Claude Code readability and processing +- No emojis in output +- Use structured template format with field labels (see output-format-spec.txt) +- Handle missing/null fields with appropriate default values + +### Error Handling +- Handle network connectivity issues with appropriate error messages +- Handle authentication failures with clear guidance +- Handle invalid issue keys with specific error messages +- Use 30-second timeout with no retry logic + +## Technical Requirements + +### Performance +- Single issue fetching only (no bulk operations) +- 30-second network timeout limit +- No local caching of issue data + +### Security +- Use environment variables for sensitive authentication data +- Support personal access token authentication only +- Do not log or expose authentication tokens + +### Compatibility +- Support single Jira instance per environment configuration +- Compatible with Jira Cloud and Server REST API v2 +- Maintain existing specware CLI patterns and conventions + +## Acceptance Criteria + +### Command Structure +- [ ] `specware jira get-issue ` command exists and is functional +- [ ] Command validates issue key parameter is provided +- [ ] Command follows existing specware error handling patterns + +### Environment Variables +- [ ] `JIRA_URL` environment variable is required and validated as non-empty +- [ ] `JIRA_API_TOKEN` environment variable is required and validated as non-empty +- [ ] Clear error messages when environment variables are missing + +### API Integration +- [ ] Successfully authenticates to Jira using personal access token +- [ ] Fetches issue data using Jira REST API `/rest/api/2/issue/{issueKey}` endpoint +- [ ] Handles API errors gracefully with user-friendly messages + +### Output Format +- [ ] Outputs formatted text suitable for Claude Code processing +- [ ] Includes issue key, summary, description, status, type, priority, assignee +- [ ] Content is unmodified from Jira (preserves original formatting) +- [ ] Output is human-readable and well-structured +- [ ] No emojis in output + +### Error Scenarios +- [ ] Handles missing environment variables +- [ ] Handles invalid Jira URLs +- [ ] Handles authentication failures +- [ ] Handles network connectivity issues +- [ ] Handles invalid or non-existent issue keys + +## Constraints + +### Scope Limitations +- Single issue fetching only (no multiple issues, linked issues, or sub-tasks) +- Standard Jira fields only (no custom field support) +- Personal access token authentication only +- Single Jira instance support per environment + +### Technical Constraints +- Must follow existing specware CLI architecture patterns +- Must use Go standard library where possible +- Must maintain consistency with existing command structure +- No additional dependencies beyond necessary HTTP client functionality + +### Dependencies +- Jira instance with REST API v2 support +- Valid personal access token with issue read permissions +- Network connectivity to Jira instance +- Go HTTP client library for API requests + +### Technical Specifications +- API Integration: See api-integration-spec.yaml for detailed API contract +- Output Format: See output-format-spec.txt for exact formatting requirements + diff --git a/CLAUDE.md b/CLAUDE.md index 53f4b4d..2fdf3a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,11 +36,16 @@ The project follows a standard Go CLI structure using Cobra for command handling - `init.go` - Project initialization command - `localize_templates.go` - Template localization command - `feature.go` - Feature management commands with status tracking + - `jira.go` - Jira integration commands for issue fetching - **`internal/spec/`** - Core business logic for spec-driven workflows - Handles project initialization with `.claude/commands`, `.claude/agents` and `.spec` directories - Manages embedded asset copying (commands, agents and templates) - Provides feature numbering and directory structure management - Implements status tracking through `.spec-status.json` files +- **`internal/jira/`** - Jira integration functionality + - HTTP client for Jira REST API communication + - Issue data types and JSON unmarshaling + - Issue formatting for Claude Code consumption - **`assets/`** - Embedded file system containing templates, commands and agents ## Key Functionality @@ -59,6 +64,7 @@ The tool creates a standardized project structure: - `specware feature new-requirements ` - Create new feature specification directory - `specware feature new-implementation-plan ` - Add implementation plan to existing feature - `specware feature update-state ` - Update feature status tracking +- `specware jira get-issue ` - Fetch Jira issue for context gathering during specifications ## Status Management @@ -74,6 +80,14 @@ Features are tracked through `.spec-status.json` files with suggested statuses: - `"Implementation Plan Interactive Review"` - `"Implementation Planning Complete"` +## Jira Integration + +The Jira integration requires environment variables for authentication: +- `JIRA_URL` - Base URL of your Jira instance (e.g., https://company.atlassian.net) +- `JIRA_API_TOKEN` - Personal access token with issue read permissions + +The `jira get-issue` command fetches issue details and formats them for Claude Code consumption during the specification workflow. This enables context gathering from existing Jira tickets when creating feature specifications. + ## Testing -Uses Ginkgo v2 and Gomega for BDD-style testing. Test files are located in `internal/spec/` with the pattern `*_test.go` and `*_suite_test.go`. \ No newline at end of file +Uses Ginkgo v2 and Gomega for BDD-style testing. Test files are located in `internal/spec/` and `internal/jira/` with the pattern `*_test.go` and `*_suite_test.go`. \ No newline at end of file diff --git a/README.md b/README.md index 23db981..f12073a 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,10 @@ These commands are intended to be run by Claude Code to facilitate feature speci - `feature new-implementation-plan ` - Add implementation plan to existing feature - `feature update-state ` - Update feature development status +#### Jira Integration +These commands are intended to be run by Claude Code during the specification workflow to gather context from Jira: +- `jira get-issue ` - Fetch and display a single Jira issue for context gathering + ### Claude Command (/specify) Interactive Claude Command with three primary workflows: diff --git a/assets/commands/specify.md b/assets/commands/specify.md index be2378f..06b67cc 100644 --- a/assets/commands/specify.md +++ b/assets/commands/specify.md @@ -11,9 +11,17 @@ $ARGUMENTS should be either: 1. Feature requirements, indiciating a new feature specification should be started 2. Feedback on requirements or implementation plans, indicating that finalization step is likely in progress and review is occurring asynchronously. 3. A short name, indicating the user wants to pick up where they left off in this workflow for the feature with the given short name. +4. A JIRA issue key (following pattern PROJ-1234), indicating that JIRA issue details should be gathered first. If unsure, ask for clarification or request a feature name. +#### JIRA Issue Detection and Gathering +If $ARGUMENTS contains a JIRA issue key (pattern: uppercase letters, dash, numbers like PROJ-1234): +1. Use `specware jira get-issue ` to fetch the JIRA issue details +2. Extract the issue summary, description, and relevant details +3. Use the JIRA information as the basis for feature requirements in the workflow +4. Continue with Phase 1: Requirements Building using the JIRA details as input + ### Phase 1: Requirements Building Guide the user through generating a requirements specification. @@ -219,6 +227,9 @@ If the specware tool is not avaialble, immediately stop and instruct the user to specware feature new-implementation-plan # Add implementation plan to feature (creates dir if not exist) specware feature update-state # Update feature development status +**JIRA Integration** + specware jira get-issue # Fetch JIRA issue details by key (e.g., PROJ-1234) + **Directory Structure Created** .claude/commands/ # Claude Code commands (includes specify.md workflow) diff --git a/cmd/cmd_suite_test.go b/cmd/cmd_suite_test.go new file mode 100644 index 0000000..9005a2f --- /dev/null +++ b/cmd/cmd_suite_test.go @@ -0,0 +1,13 @@ +package cmd_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCmd(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Command Suite") +} \ No newline at end of file diff --git a/cmd/jira.go b/cmd/jira.go new file mode 100644 index 0000000..dc3610b --- /dev/null +++ b/cmd/jira.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + "specware/internal/jira" +) + +var jiraCmd = &cobra.Command{ + Use: "jira", + Short: "Jira integration commands", + Long: `Commands for integrating with Jira to fetch issue information.`, +} + +var getIssueCmd = &cobra.Command{ + Use: "get-issue ", + Short: "Fetch a single Jira issue", + Long: `Fetch and display a single Jira issue by its key. + +Requires environment variables: +- JIRA_URL: The base URL of your Jira instance (e.g., https://company.atlassian.net) +- JIRA_API_TOKEN: Your personal access token with issue read permissions + +Example: + specware jira get-issue PROJ-123`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + issueKey := args[0] + + // Validate environment variables + if err := jira.ValidateEnvironment(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Create client + jiraURL := os.Getenv("JIRA_URL") + apiToken := os.Getenv("JIRA_API_TOKEN") + client := jira.NewClient(jiraURL, apiToken) + + // Fetch issue + ctx := context.Background() + issue, err := client.GetIssue(ctx, issueKey) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Format and display output + output := jira.FormatIssue(issue) + fmt.Print(output) + }, +} + +func init() { + jiraCmd.AddCommand(getIssueCmd) +} + +// CreateJiraCommand creates a new jira command for testing +func CreateJiraCommand() *cobra.Command { + // Return a copy of the main jira command for testing + cmd := *jiraCmd + cmd.ResetCommands() + + // Create a copy of the get-issue command + getIssue := *getIssueCmd + cmd.AddCommand(&getIssue) + + return &cmd +} \ No newline at end of file diff --git a/cmd/jira_test.go b/cmd/jira_test.go new file mode 100644 index 0000000..1c5ede0 --- /dev/null +++ b/cmd/jira_test.go @@ -0,0 +1,74 @@ +package cmd_test + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "specware/cmd" +) + +var _ = Describe("Jira Command Integration", func() { + var ( + originalJiraURL string + originalAPIToken string + ) + + BeforeEach(func() { + // Save original environment variables + originalJiraURL = os.Getenv("JIRA_URL") + originalAPIToken = os.Getenv("JIRA_API_TOKEN") + }) + + AfterEach(func() { + // Restore original environment variables + if originalJiraURL != "" { + os.Setenv("JIRA_URL", originalJiraURL) + } else { + os.Unsetenv("JIRA_URL") + } + + if originalAPIToken != "" { + os.Setenv("JIRA_API_TOKEN", originalAPIToken) + } else { + os.Unsetenv("JIRA_API_TOKEN") + } + }) + + Describe("CreateJiraCommand", func() { + It("should create a valid jira command", func() { + jiraCmd := cmd.CreateJiraCommand() + + Expect(jiraCmd.Use).To(Equal("jira")) + Expect(jiraCmd.Short).To(ContainSubstring("Jira integration")) + Expect(jiraCmd.HasSubCommands()).To(BeTrue()) + + subCommands := jiraCmd.Commands() + Expect(len(subCommands)).To(Equal(1)) + Expect(subCommands[0].Use).To(ContainSubstring("get-issue")) + }) + }) + + Describe("Command structure validation", func() { + It("should have correct command hierarchy", func() { + jiraCmd := cmd.CreateJiraCommand() + getIssueCmd, _, err := jiraCmd.Find([]string{"get-issue"}) + + Expect(err).NotTo(HaveOccurred()) + Expect(getIssueCmd.Use).To(ContainSubstring("get-issue")) + Expect(getIssueCmd.Args).NotTo(BeNil()) // Should have argument validation + }) + }) + + Describe("Help text validation", func() { + It("should contain required environment variable documentation", func() { + jiraCmd := cmd.CreateJiraCommand() + getIssueCmd, _, err := jiraCmd.Find([]string{"get-issue"}) + + Expect(err).NotTo(HaveOccurred()) + Expect(getIssueCmd.Long).To(ContainSubstring("JIRA_URL")) + Expect(getIssueCmd.Long).To(ContainSubstring("JIRA_API_TOKEN")) + Expect(getIssueCmd.Long).To(ContainSubstring("specware jira get-issue")) + }) + }) +}) \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index 43db5a9..b2131ec 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,4 +23,5 @@ func init() { rootCmd.AddCommand(initCmd) rootCmd.AddCommand(localizeTemplatesCmd) rootCmd.AddCommand(featureCmd) + rootCmd.AddCommand(jiraCmd) } \ No newline at end of file diff --git a/internal/jira/client.go b/internal/jira/client.go new file mode 100644 index 0000000..f255d75 --- /dev/null +++ b/internal/jira/client.go @@ -0,0 +1,125 @@ +package jira + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "time" +) + +// Client represents a Jira HTTP client +type Client struct { + BaseURL string + APIToken string + HTTPClient *http.Client +} + +// NewClient creates a new Jira client +func NewClient(baseURL, apiToken string) *Client { + return &Client{ + BaseURL: baseURL, + APIToken: apiToken, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// ValidateEnvironment checks that required environment variables are set +func ValidateEnvironment() error { + jiraURL := os.Getenv("JIRA_URL") + if jiraURL == "" { + return fmt.Errorf("JIRA_URL environment variable is required") + } + + apiToken := os.Getenv("JIRA_API_TOKEN") + if apiToken == "" { + return fmt.Errorf("JIRA_API_TOKEN environment variable is required") + } + + // Validate URL format + if _, err := url.Parse(jiraURL); err != nil { + return fmt.Errorf("invalid JIRA_URL format: %w", err) + } + + return nil +} + +// ValidateIssueKey validates the issue key format +func ValidateIssueKey(issueKey string) error { + // Pattern from specification: ^[A-Za-z]+-[0-9]+$ + pattern := `^[A-Za-z]+-[0-9]+$` + matched, err := regexp.MatchString(pattern, issueKey) + if err != nil { + return fmt.Errorf("error validating issue key pattern: %w", err) + } + if !matched { + return fmt.Errorf("invalid issue key format. Expected format: PROJECT-123") + } + return nil +} + +// GetIssue fetches a single Jira issue by key +func (c *Client) GetIssue(ctx context.Context, issueKey string) (*Issue, error) { + // Validate issue key format + if err := ValidateIssueKey(issueKey); err != nil { + return nil, err + } + + // Construct URL + endpoint := fmt.Sprintf("%s/rest/api/2/issue/%s", strings.TrimRight(c.BaseURL, "/"), issueKey) + + // Create request + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Authorization", "Bearer "+c.APIToken) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + // Make request + resp, err := c.HTTPClient.Do(req) + if err != nil { + // Check for timeout + if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "timeout") { + return nil, fmt.Errorf("network timeout after 30 seconds") + } + return nil, fmt.Errorf("network error: %w", err) + } + defer resp.Body.Close() + + // Handle different status codes per specification + switch resp.StatusCode { + case http.StatusOK: + // Success - parse response + var issue Issue + if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &issue, nil + + case http.StatusUnauthorized: + return nil, fmt.Errorf("authentication failed. Check JIRA_API_TOKEN environment variable") + + case http.StatusForbidden: + return nil, fmt.Errorf("access denied to issue '%s'. Verify you have permission to view this issue", issueKey) + + case http.StatusNotFound: + return nil, fmt.Errorf("issue '%s' not found. Verify the issue key exists and you have permission to view it", issueKey) + + case http.StatusInternalServerError: + return nil, fmt.Errorf("Jira server error. Try again later or contact your Jira administrator") + + default: + return nil, fmt.Errorf("unexpected response status: %d", resp.StatusCode) + } +} \ No newline at end of file diff --git a/internal/jira/client_test.go b/internal/jira/client_test.go new file mode 100644 index 0000000..b833669 --- /dev/null +++ b/internal/jira/client_test.go @@ -0,0 +1,325 @@ +package jira_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "specware/internal/jira" +) + +var _ = Describe("Client", func() { + var ( + client *jira.Client + server *httptest.Server + ) + + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Default handler - will be overridden in tests + w.WriteHeader(http.StatusOK) + })) + client = jira.NewClient(server.URL, "test-token") + }) + + AfterEach(func() { + server.Close() + }) + + Describe("ValidateEnvironment", func() { + var originalJiraURL, originalAPIToken string + + BeforeEach(func() { + originalJiraURL = os.Getenv("JIRA_URL") + originalAPIToken = os.Getenv("JIRA_API_TOKEN") + }) + + AfterEach(func() { + os.Setenv("JIRA_URL", originalJiraURL) + os.Setenv("JIRA_API_TOKEN", originalAPIToken) + }) + + Context("when both environment variables are set", func() { + BeforeEach(func() { + os.Setenv("JIRA_URL", "https://test.atlassian.net") + os.Setenv("JIRA_API_TOKEN", "test-token") + }) + + It("should return no error", func() { + err := jira.ValidateEnvironment() + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("when JIRA_URL is missing", func() { + BeforeEach(func() { + os.Unsetenv("JIRA_URL") + os.Setenv("JIRA_API_TOKEN", "test-token") + }) + + It("should return an error", func() { + err := jira.ValidateEnvironment() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("JIRA_URL environment variable is required")) + }) + }) + + Context("when JIRA_API_TOKEN is missing", func() { + BeforeEach(func() { + os.Setenv("JIRA_URL", "https://test.atlassian.net") + os.Unsetenv("JIRA_API_TOKEN") + }) + + It("should return an error", func() { + err := jira.ValidateEnvironment() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("JIRA_API_TOKEN environment variable is required")) + }) + }) + + Context("when JIRA_URL has invalid format", func() { + BeforeEach(func() { + os.Setenv("JIRA_URL", "://invalid-url") + os.Setenv("JIRA_API_TOKEN", "test-token") + }) + + It("should return an error", func() { + err := jira.ValidateEnvironment() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid JIRA_URL format")) + }) + }) + + Context("when JIRA_URL is malformed", func() { + BeforeEach(func() { + os.Setenv("JIRA_URL", "ht\ttp://invalid") + os.Setenv("JIRA_API_TOKEN", "test-token") + }) + + It("should return an error", func() { + err := jira.ValidateEnvironment() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid JIRA_URL format")) + }) + }) + }) + + Describe("ValidateIssueKey", func() { + Context("with valid issue keys", func() { + DescribeTable("should accept valid formats", + func(issueKey string) { + err := jira.ValidateIssueKey(issueKey) + Expect(err).NotTo(HaveOccurred()) + }, + Entry("uppercase project", "PROJ-123"), + Entry("lowercase project", "proj-123"), + Entry("mixed case project", "Proj-123"), + Entry("another mixed case", "pRoJ-456"), + Entry("long project name", "PROJECTNAME-999"), + Entry("single letter project", "A-1"), + Entry("single letter lowercase", "a-1"), + ) + }) + + Context("with invalid issue keys", func() { + DescribeTable("should reject invalid formats", + func(issueKey string) { + err := jira.ValidateIssueKey(issueKey) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid issue key format")) + }, + Entry("missing project", "123"), + Entry("missing number", "PROJ-"), + Entry("no dash", "PROJ123"), + Entry("multiple dashes", "PROJ-123-456"), + Entry("empty string", ""), + Entry("special characters in project", "PR@J-123"), + Entry("letters in number", "PROJ-12a"), + ) + }) + }) + + Describe("GetIssue", func() { + Context("when the request is successful", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("GET")) + Expect(r.URL.Path).To(Equal("/rest/api/2/issue/PROJ-123")) + Expect(r.Header.Get("Authorization")).To(Equal("Bearer test-token")) + Expect(r.Header.Get("Accept")).To(Equal("application/json")) + Expect(r.Header.Get("Content-Type")).To(Equal("application/json")) + + issue := jira.Issue{ + Key: "PROJ-123", + Fields: jira.IssueFields{ + Summary: "Test issue", + Description: "Test description", + Status: &jira.Status{Name: "In Progress"}, + IssueType: &jira.IssueType{Name: "Bug"}, + Priority: &jira.Priority{Name: "High"}, + Assignee: &jira.User{DisplayName: "John Doe"}, + Reporter: &jira.User{DisplayName: "Jane Smith"}, + Created: jira.JiraTime{Time: time.Now()}, + Updated: jira.JiraTime{Time: time.Now()}, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(issue) + })) + client = jira.NewClient(server.URL, "test-token") + }) + + It("should return the issue", func() { + ctx := context.Background() + issue, err := client.GetIssue(ctx, "PROJ-123") + + Expect(err).NotTo(HaveOccurred()) + Expect(issue).NotTo(BeNil()) + Expect(issue.Key).To(Equal("PROJ-123")) + Expect(issue.Fields.Summary).To(Equal("Test issue")) + Expect(issue.Fields.Status.Name).To(Equal("In Progress")) + }) + }) + + Context("when the issue key is invalid", func() { + It("should return a validation error", func() { + ctx := context.Background() + _, err := client.GetIssue(ctx, "invalid-key") + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid issue key format")) + }) + }) + + Context("when authentication fails", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + client = jira.NewClient(server.URL, "invalid-token") + }) + + It("should return an authentication error", func() { + ctx := context.Background() + _, err := client.GetIssue(ctx, "PROJ-123") + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("authentication failed")) + Expect(err.Error()).To(ContainSubstring("JIRA_API_TOKEN")) + }) + }) + + Context("when access is denied", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + client = jira.NewClient(server.URL, "test-token") + }) + + It("should return a permission error", func() { + ctx := context.Background() + _, err := client.GetIssue(ctx, "PROJ-123") + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("access denied")) + Expect(err.Error()).To(ContainSubstring("PROJ-123")) + }) + }) + + Context("when issue is not found", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + client = jira.NewClient(server.URL, "test-token") + }) + + It("should return a not found error", func() { + ctx := context.Background() + _, err := client.GetIssue(ctx, "PROJ-999") + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not found")) + Expect(err.Error()).To(ContainSubstring("PROJ-999")) + }) + }) + + Context("when server returns internal error", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + client = jira.NewClient(server.URL, "test-token") + }) + + It("should return a server error", func() { + ctx := context.Background() + _, err := client.GetIssue(ctx, "PROJ-123") + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("server error")) + }) + }) + + Context("when response is malformed JSON", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("invalid json")) + })) + client = jira.NewClient(server.URL, "test-token") + }) + + It("should return a parsing error", func() { + ctx := context.Background() + _, err := client.GetIssue(ctx, "PROJ-123") + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to parse response")) + }) + }) + + Context("when network timeout occurs", func() { + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate a slow response that exceeds timeout + time.Sleep(2 * time.Second) + w.WriteHeader(http.StatusOK) + })) + client = jira.NewClient(server.URL, "test-token") + // Override timeout for testing + client.HTTPClient.Timeout = 1 * time.Second + }) + + It("should return a timeout error", func() { + ctx := context.Background() + _, err := client.GetIssue(ctx, "PROJ-123") + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timeout")) + }) + }) + + Context("when server is unreachable", func() { + BeforeEach(func() { + // Use an invalid URL that will cause network error + client = jira.NewClient("http://invalid-url-that-does-not-exist.local", "test-token") + }) + + It("should return a network error", func() { + ctx := context.Background() + _, err := client.GetIssue(ctx, "PROJ-123") + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("network error")) + }) + }) + }) +}) \ No newline at end of file diff --git a/internal/jira/formatter.go b/internal/jira/formatter.go new file mode 100644 index 0000000..5792529 --- /dev/null +++ b/internal/jira/formatter.go @@ -0,0 +1,65 @@ +package jira + +import ( + "fmt" + "strings" +) + +// FormatIssue formats a Jira issue according to the output specification +func FormatIssue(issue *Issue) string { + var output strings.Builder + + // Header line: Issue: {key} + output.WriteString(fmt.Sprintf("Issue: %s\n", issue.Key)) + + // Title: {summary} + summary := getFieldOrDefault(issue.Fields.Summary, "No title provided") + output.WriteString(fmt.Sprintf("Title: %s\n", summary)) + + // Type: {issuetype} + issueType := "Unknown" + if issue.Fields.IssueType != nil { + issueType = getFieldOrDefault(issue.Fields.IssueType.Name, "Unknown") + } + output.WriteString(fmt.Sprintf("Type: %s\n", issueType)) + + // Status: {status} + status := "Unknown" + if issue.Fields.Status != nil { + status = getFieldOrDefault(issue.Fields.Status.Name, "Unknown") + } + output.WriteString(fmt.Sprintf("Status: %s\n", status)) + + // Priority: {priority} + priority := "None" + if issue.Fields.Priority != nil { + priority = getFieldOrDefault(issue.Fields.Priority.Name, "None") + } + output.WriteString(fmt.Sprintf("Priority: %s\n", priority)) + + // Assignee: {assignee} + assignee := "Not assigned" + if issue.Fields.Assignee != nil { + assignee = getFieldOrDefault(issue.Fields.Assignee.DisplayName, "Not assigned") + } + output.WriteString(fmt.Sprintf("Assignee: %s\n", assignee)) + + // Blank line before description + output.WriteString("\n") + + // Description section + output.WriteString("Description:\n") + description := getFieldOrDefault(issue.Fields.Description, "No description provided") + output.WriteString(description) + output.WriteString("\n") + + return output.String() +} + +// getFieldOrDefault returns the field value if non-empty, otherwise returns the default +func getFieldOrDefault(field, defaultValue string) string { + if strings.TrimSpace(field) == "" { + return defaultValue + } + return field +} \ No newline at end of file diff --git a/internal/jira/formatter_test.go b/internal/jira/formatter_test.go new file mode 100644 index 0000000..280db2b --- /dev/null +++ b/internal/jira/formatter_test.go @@ -0,0 +1,205 @@ +package jira_test + +import ( + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "specware/internal/jira" +) + +var _ = Describe("Formatter", func() { + Describe("FormatIssue", func() { + Context("with a complete issue", func() { + It("should format all fields correctly", func() { + issue := &jira.Issue{ + Key: "PROJ-123", + Fields: jira.IssueFields{ + Summary: "Fix authentication bug", + Description: "Users are experiencing authentication failures", + Status: &jira.Status{Name: "In Progress"}, + IssueType: &jira.IssueType{Name: "Bug"}, + Priority: &jira.Priority{Name: "High"}, + Assignee: &jira.User{DisplayName: "John Smith"}, + Reporter: &jira.User{DisplayName: "Jane Doe"}, + Created: jira.JiraTime{Time: time.Now()}, + Updated: jira.JiraTime{Time: time.Now()}, + }, + } + + output := jira.FormatIssue(issue) + + expectedOutput := `Issue: PROJ-123 +Title: Fix authentication bug +Type: Bug +Status: In Progress +Priority: High +Assignee: John Smith + +Description: +Users are experiencing authentication failures` + + Expect(output).To(Equal(expectedOutput)) + }) + }) + + Context("with missing optional fields", func() { + It("should use default values", func() { + issue := &jira.Issue{ + Key: "PROJ-456", + Fields: jira.IssueFields{ + Summary: "Test issue", + Description: "", + Status: nil, + IssueType: nil, + Priority: nil, + Assignee: nil, + }, + } + + output := jira.FormatIssue(issue) + + expectedOutput := `Issue: PROJ-456 +Title: Test issue +Type: Unknown +Status: Unknown +Priority: None +Assignee: Not assigned + +Description: +No description provided` + + Expect(output).To(Equal(expectedOutput)) + }) + }) + + Context("with empty summary", func() { + It("should use default title", func() { + issue := &jira.Issue{ + Key: "PROJ-789", + Fields: jira.IssueFields{ + Summary: "", + Description: "Some description", + Status: &jira.Status{Name: "Done"}, + IssueType: &jira.IssueType{Name: "Story"}, + Priority: &jira.Priority{Name: "Medium"}, + Assignee: &jira.User{DisplayName: "Alice Brown"}, + }, + } + + output := jira.FormatIssue(issue) + + Expect(output).To(ContainSubstring("Title: No title provided")) + }) + }) + + Context("with whitespace-only fields", func() { + It("should treat them as empty", func() { + issue := &jira.Issue{ + Key: "PROJ-999", + Fields: jira.IssueFields{ + Summary: " ", + Description: "\t\n", + Status: &jira.Status{Name: " "}, + IssueType: &jira.IssueType{Name: ""}, + Priority: &jira.Priority{Name: "Low"}, + Assignee: &jira.User{DisplayName: " "}, + }, + } + + output := jira.FormatIssue(issue) + + Expect(output).To(ContainSubstring("Title: No title provided")) + Expect(output).To(ContainSubstring("Type: Unknown")) + Expect(output).To(ContainSubstring("Status: Unknown")) + Expect(output).To(ContainSubstring("Assignee: Not assigned")) + Expect(output).To(ContainSubstring("Description:\nNo description provided")) + }) + }) + + Context("with multiline description", func() { + It("should preserve formatting", func() { + description := `This is a bug report. + +Steps to reproduce: +1. Open the app +2. Click login +3. Enter credentials + +Expected: Success +Actual: Error` + + issue := &jira.Issue{ + Key: "PROJ-100", + Fields: jira.IssueFields{ + Summary: "Login bug", + Description: description, + Status: &jira.Status{Name: "Open"}, + IssueType: &jira.IssueType{Name: "Bug"}, + Priority: &jira.Priority{Name: "Critical"}, + Assignee: &jira.User{DisplayName: "Developer One"}, + }, + } + + output := jira.FormatIssue(issue) + + Expect(output).To(ContainSubstring("Description:\n" + description)) + }) + }) + + Context("with special characters in fields", func() { + It("should preserve them without modification", func() { + issue := &jira.Issue{ + Key: "PROJ-200", + Fields: jira.IssueFields{ + Summary: "Handle & < > \" ' characters", + Description: "Test with & < > \" ' special chars", + Status: &jira.Status{Name: "In Review"}, + IssueType: &jira.IssueType{Name: "Task"}, + Priority: &jira.Priority{Name: "Low"}, + Assignee: &jira.User{DisplayName: "O'Brien"}, + }, + } + + output := jira.FormatIssue(issue) + + Expect(output).To(ContainSubstring("Handle & < > \" ' characters")) + Expect(output).To(ContainSubstring("Test with & < > \" ' special chars")) + Expect(output).To(ContainSubstring("O'Brien")) + }) + }) + + Context("output format validation", func() { + It("should match the exact specification format", func() { + issue := &jira.Issue{ + Key: "TEST-42", + Fields: jira.IssueFields{ + Summary: "Sample issue", + Description: "Sample description", + Status: &jira.Status{Name: "To Do"}, + IssueType: &jira.IssueType{Name: "Epic"}, + Priority: &jira.Priority{Name: "Highest"}, + Assignee: &jira.User{DisplayName: "Test User"}, + }, + } + + output := jira.FormatIssue(issue) + + lines := strings.Split(output, "\n") + Expect(lines[0]).To(Equal("Issue: TEST-42")) + Expect(lines[1]).To(Equal("Title: Sample issue")) + Expect(lines[2]).To(Equal("Type: Epic")) + Expect(lines[3]).To(Equal("Status: To Do")) + Expect(lines[4]).To(Equal("Priority: Highest")) + Expect(lines[5]).To(Equal("Assignee: Test User")) + Expect(lines[6]).To(Equal("")) + Expect(lines[7]).To(Equal("Description:")) + Expect(lines[8]).To(Equal("Sample description")) + + // No trailing newlines + Expect(output).NotTo(HaveSuffix("\n\n")) + }) + }) + }) +}) \ No newline at end of file diff --git a/internal/jira/jira_suite_test.go b/internal/jira/jira_suite_test.go new file mode 100644 index 0000000..3449d2c --- /dev/null +++ b/internal/jira/jira_suite_test.go @@ -0,0 +1,13 @@ +package jira_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestJira(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Jira Suite") +} \ No newline at end of file diff --git a/internal/jira/types.go b/internal/jira/types.go new file mode 100644 index 0000000..2d191d5 --- /dev/null +++ b/internal/jira/types.go @@ -0,0 +1,79 @@ +package jira + +import ( + "fmt" + "strings" + "time" +) + +// JiraTime is a custom time type that handles Jira's timestamp format +type JiraTime struct { + time.Time +} + +// UnmarshalJSON implements json.Unmarshaler for JiraTime +func (jt *JiraTime) UnmarshalJSON(data []byte) error { + // Remove quotes from the JSON string + str := strings.Trim(string(data), `"`) + + // Handle Jira's timestamp format: "2022-05-30T08:01:52.885+0000" + // Convert "+0000" to "+00:00" for RFC3339 compatibility + if strings.HasSuffix(str, "+0000") { + str = str[:len(str)-5] + "+00:00" + } else if strings.HasSuffix(str, "-0000") { + str = str[:len(str)-5] + "-00:00" + } + + // Try parsing with milliseconds first + t, err := time.Parse("2006-01-02T15:04:05.000-07:00", str) + if err != nil { + // Try parsing without milliseconds as fallback + t, err = time.Parse("2006-01-02T15:04:05-07:00", str) + if err != nil { + return fmt.Errorf("unable to parse time %q: %w", str, err) + } + } + + jt.Time = t + return nil +} + +// Issue represents a Jira issue response +type Issue struct { + Key string `json:"key"` + Fields IssueFields `json:"fields"` +} + +// IssueFields represents the fields section of a Jira issue +type IssueFields struct { + Summary string `json:"summary"` + Description string `json:"description"` + Status *Status `json:"status"` + IssueType *IssueType `json:"issuetype"` + Priority *Priority `json:"priority"` + Assignee *User `json:"assignee"` + Reporter *User `json:"reporter"` + Created JiraTime `json:"created"` + Updated JiraTime `json:"updated"` +} + +// Status represents a Jira issue status +type Status struct { + Name string `json:"name"` +} + +// IssueType represents a Jira issue type +type IssueType struct { + Name string `json:"name"` +} + +// Priority represents a Jira issue priority +type Priority struct { + Name string `json:"name"` +} + +// User represents a Jira user +type User struct { + DisplayName string `json:"displayName"` + EmailAddress string `json:"emailAddress"` +} \ No newline at end of file