diff --git a/CLAUDE.md b/CLAUDE.md index 56033cf..1b6b978 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,7 @@ Auto-generated from all feature plans. Last updated: 2025-11-28 ## Active Technologies - Python 3.9+ (per constitution, leveraging type hints) + Standard library (urllib, json, csv, os, re); optional: requests (002-jira-integration) - CSV files for export (same as existing GitHub exports) (002-jira-integration) +- Python 3.9+ (per constitution, leveraging type hints) + Standard library only (urllib, json, csv, os, re, datetime, statistics); optional: requests (already used in jira_client.py) (003-jira-quality-metrics) - Python 3.9+ (as per constitution, leveraging type hints) + Standard library only (urllib, json, csv, os, re); optional: requests (001-modular-refactor) @@ -36,6 +37,7 @@ python github_analyzer.py --days 7 Python 3.9+ (as per constitution, leveraging type hints): Follow standard conventions ## Recent Changes +- 003-jira-quality-metrics: Added Python 3.9+ (per constitution, leveraging type hints) + Standard library only (urllib, json, csv, os, re, datetime, statistics); optional: requests (already used in jira_client.py) - 002-jira-integration: Added Python 3.9+ (per constitution, leveraging type hints) + Standard library (urllib, json, csv, os, re); optional: requests - 001-modular-refactor: Added Python 3.9+ (as per constitution, leveraging type hints) + Standard library only (urllib, json, csv, os, re); optional: requests diff --git a/README.md b/README.md index fa8f355..218bba3 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,14 @@ A powerful Python command-line tool for analyzing GitHub repositories and Jira p - **Quality Metrics** - Assess code quality through revert ratios, review coverage, and commit message analysis - **Productivity Scoring** - Calculate composite productivity scores for contributors across repositories -### Jira Integration (NEW) +### Jira Integration - **Jira Issue Extraction** - Extract issues and comments from Jira Cloud and Server/Data Center +- **Quality Metrics** - Calculate 10 quality metrics per issue including cycle time, description quality, collaboration scores +- **Aggregated Reports** - Generate project-level, person-level, and issue-type summaries - **Multi-Project Support** - Analyze multiple Jira projects with interactive project selection - **Time-Based Filtering** - Filter issues by update date using JQL queries - **Comment Tracking** - Export all issue comments with author and timestamp +- **Reopen Tracking** - Detect reopened issues via changelog API (best-effort, graceful degradation) - **ADF Support** - Automatically converts Atlassian Document Format to plain text ### Core Features @@ -222,12 +225,15 @@ The analyzer generates CSV files in the output directory. GitHub outputs are alw | `productivity_analysis.csv` | Per-contributor productivity metrics and scores | | `contributors_summary.csv` | Contributor overview with commit and PR statistics | -**Jira outputs (2 files):** +**Jira outputs (5 files):** | File | Description | |------|-------------| -| `jira_issues_export.csv` | Jira issues with key, summary, status, type, priority, assignee, reporter, dates | +| `jira_issues_export.csv` | Jira issues with 12 base fields + 10 quality metrics per issue | | `jira_comments_export.csv` | Jira issue comments with issue key, author, date, body | +| `jira_project_metrics.csv` | Aggregated metrics per project (cycle time, bug ratio, quality scores) | +| `jira_person_metrics.csv` | Per-assignee metrics (WIP count, resolved count, avg cycle time) | +| `jira_type_metrics.csv` | Per-issue-type metrics (counts, avg cycle time, bug resolution time) | ### CSV Field Details @@ -267,9 +273,79 @@ first_activity, last_activity, active_days, consistency_pct, productivity_score ``` -## Quality Metrics Explained +#### jira_issues_export.csv +``` +key, summary, description, status, issue_type, priority, assignee, reporter, +created, updated, resolution_date, project_key, +cycle_time_days, aging_days, comments_count, description_quality_score, +acceptance_criteria_present, comment_velocity_hours, silent_issue, +same_day_resolution, cross_team_score, reopen_count +``` + +#### jira_project_metrics.csv +``` +project_key, total_issues, resolved_count, unresolved_count, +avg_cycle_time_days, median_cycle_time_days, bug_count, bug_ratio_percent, +same_day_resolution_rate_percent, avg_description_quality, +silent_issues_ratio_percent, avg_comments_per_issue, +avg_comment_velocity_hours, reopen_rate_percent +``` + +#### jira_person_metrics.csv +``` +assignee_name, wip_count, resolved_count, total_assigned, +avg_cycle_time_days, bug_count_assigned +``` + +#### jira_type_metrics.csv +``` +issue_type, count, resolved_count, avg_cycle_time_days, +bug_resolution_time_avg +``` -The analyzer calculates several quality indicators: +## Jira Quality Metrics Explained + +The analyzer calculates 10 quality metrics for each Jira issue: + +| Metric | Description | Value | +|--------|-------------|-------| +| **cycle_time_days** | Days from created to resolution | Float (empty if open) | +| **aging_days** | Days since creation for open issues | Float (empty if resolved) | +| **comments_count** | Total number of comments | Integer | +| **description_quality_score** | Quality score based on length, AC, formatting | 0-100 | +| **acceptance_criteria_present** | AC patterns detected (Given/When/Then, checkboxes) | true/false | +| **comment_velocity_hours** | Hours from creation to first comment | Float (empty if silent) | +| **silent_issue** | No comments exist | true/false | +| **same_day_resolution** | Resolved on same day as created | true/false | +| **cross_team_score** | Collaboration score based on distinct commenters | 0-100 | +| **reopen_count** | Times reopened (Done→non-Done transitions) | Integer | + +### Description Quality Score Calculation + +The quality score (0-100) uses balanced weighting: + +| Component | Weight | Criteria | +|-----------|--------|----------| +| **Length** | 40% | Linear scale: 100+ chars = full score | +| **Acceptance Criteria** | 40% | Detected patterns: Given/When/Then, AC:, checkboxes | +| **Formatting** | 20% | Headers (10%) + Lists (10%) detected | + +### Cross-Team Collaboration Score + +Based on distinct comment authors: + +| Authors | Score | +|---------|-------| +| 0 | 0 | +| 1 | 25 | +| 2 | 50 | +| 3 | 75 | +| 4 | 90 | +| 5+ | 100 | + +## GitHub Quality Metrics Explained + +The analyzer calculates several quality indicators for GitHub repositories: | Metric | Description | Ideal | |--------|-------------|-------| diff --git a/specs/003-jira-quality-metrics/checklists/comprehensive.md b/specs/003-jira-quality-metrics/checklists/comprehensive.md new file mode 100644 index 0000000..7e4e931 --- /dev/null +++ b/specs/003-jira-quality-metrics/checklists/comprehensive.md @@ -0,0 +1,158 @@ +# Requirements Quality Checklist: Jira Quality Metrics Export + +**Purpose**: Comprehensive requirements quality validation for PR review +**Created**: 2025-11-28 +**Feature**: [spec.md](../spec.md) +**Depth**: Standard (PR Review) +**Audience**: Reviewer + +--- + +## Algorithm/Calculation Requirements + +- [x] CHK001 - Is the linear interpolation for length score (0-100 chars → 0-40 points) explicitly defined? [Clarity, Spec §FR-004] + - ✓ Defined in research.md §3: `length_score = min(40, int(length * 40 / 100))` and data-model.md §Configuration +- [x] CHK002 - Are the exact regex patterns for acceptance criteria detection documented? [Completeness, Spec §FR-005] + - ✓ Defined in research.md §2 and data-model.md §Configuration Constants: 5 AC_PATTERNS listed +- [x] CHK003 - Is the diminishing scale for cross_team_score (1=25, 2=50, 3=75, 4=90, 5+=100) applied to 0 authors? [Gap, Spec §FR-009] + - ✓ Defined in research.md §4: returns 0 for empty comments list +- [x] CHK004 - Is "same day" for same_day_resolution defined in terms of timezone handling? [Ambiguity, Spec §FR-008] + - ✓ Implicitly: comparison uses created and resolution_date which are ISO8601 with timezone (contracts/csv-schemas.md) +- [x] CHK005 - Is the precision for cycle_time_days calculation specified (integer days vs fractional)? [Clarity, Spec §FR-001] + - ✓ data-model.md: `cycle_time_days: float | None`; contracts: 2 decimal places +- [x] CHK006 - Are the formatting detection patterns (headers/lists) for quality score explicitly listed? [Completeness, Spec §FR-004] + - ✓ Defined in research.md §3: `r'^#+\s'` for headers, `r'^\s*[-*]\s'` for lists +- [x] CHK007 - Is the calculation formula for avg_comment_velocity_hours specified (excludes silent issues)? [Clarity, Spec §FR-014] + - ✓ data-model.md §ProjectMetrics: "Mean comment_velocity for non-silent issues" + +--- + +## Data Model & Schema Requirements + +- [x] CHK008 - Is the relationship between cycle_time_days and aging_days mutual exclusion clearly stated? [Consistency, Data Model] + - ✓ data-model.md §IssueMetrics: "cycle_time_days: Days from created to resolution (None if unresolved)" and "aging_days: Days from created to now (None if resolved)" +- [x] CHK009 - Are all 10 new CSV columns documented with exact data types and formats? [Completeness, Contracts §1] + - ✓ contracts/csv-schemas.md §1 Schema table: all 10 columns with Type, Description, Example +- [x] CHK010 - Is the handling of None/null values in CSV export explicitly defined (empty string vs "null")? [Clarity, Contracts §1 Notes] + - ✓ contracts/csv-schemas.md §1 Notes: "Empty values: empty string (not `null` or `N/A`)" +- [x] CHK011 - Are float precision requirements (2 decimal places) consistent across all metrics? [Consistency, Contracts] + - ✓ contracts/csv-schemas.md §Validation Rules: "Floats: 2 decimal places, no thousands separator" +- [x] CHK012 - Is the boolean format ("true"/"false" lowercase) requirement applied to all boolean fields? [Consistency, Contracts §1 Notes] + - ✓ contracts/csv-schemas.md §1 Notes + §Validation Rules: "Booleans: `true` or `false` (lowercase)" +- [x] CHK013 - Are PersonMetrics validation rules for empty assignee_name consistent with edge case handling? [Consistency, Data Model] + - ✓ data-model.md §PersonMetrics: "assignee_name: Must not be empty string; issues without assignee are excluded" + spec.md §Edge Cases +- [x] CHK014 - Is the constraint `wip_count + resolved_count = total_assigned` documented in both data model and spec? [Traceability, Data Model] + - ✓ data-model.md §PersonMetrics Validation Rules: "`wip_count` + `resolved_count` = `total_assigned`" + +--- + +## Edge Case & Error Handling Requirements + +- [x] CHK015 - Is negative cycle_time handling (warning + null) behavior testable with specific criteria? [Measurability, Spec §Edge Cases] + - ✓ spec.md §Edge Cases: "System logs a warning and sets cycle_time to null"; data-model.md §Validation: "negative values logged as warning and set to None" +- [x] CHK016 - Are division-by-zero scenarios explicitly enumerated for all ratio calculations? [Coverage, Spec §Edge Cases] + - ✓ spec.md §Edge Cases: "Return 0% or null with appropriate note"; data-model.md §ProjectMetrics: "Division by zero: Return 0.0 for ratios, None for averages" +- [x] CHK017 - Is the behavior for issues with resolution_date but no created date defined? [Gap] + - ✓ spec.md §Assumptions: "Creation and resolution dates are always present and valid for resolved issues" - invalid data is out of scope +- [x] CHK018 - Are requirements for handling malformed ADF descriptions specified? [Gap, Spec §Edge Cases] + - ✓ spec.md §Edge Cases: "How is description quality score calculated for ADF format descriptions? → Convert to plain text first, then analyze" +- [x] CHK019 - Is the fallback behavior when changelog API returns 403/404 documented? [Completeness, Spec §Assumptions] + - ✓ research.md §1: "If API returns 403 (permissions) or 404, reopen_count defaults to 0 without error"; tasks.md T040 tests this +- [x] CHK020 - Are requirements defined for issues with future dates (created > now)? [Gap, Edge Case] + - ✓ Handled by CHK015 - negative cycle_time results in warning + null; aging_days would be negative → same treatment +- [x] CHK021 - Is handling of issues re-assigned during analysis period specified for PersonMetrics? [Gap] + - ✓ Implicitly: PersonMetrics aggregates by current assignee field value at export time (snapshot-based, not historical) + +--- + +## API Integration Requirements + +- [x] CHK022 - Is the Jira changelog API endpoint path explicitly documented for v2 and v3? [Completeness, Gap] + - ✓ research.md §1: "GET /rest/api/{version}/issue/{issueKey}/changelog" - version parameterized +- [x] CHK023 - Are retry/timeout requirements for changelog API calls specified? [Gap] + - ✓ Inherits from constitution §API Client Standards: "configurable timeouts (default: 30s)", "exponential backoff for transient failures" +- [x] CHK024 - Is the "Done" status list (Done, Closed, Resolved, Complete, Completed) configurable or hardcoded? [Clarity, Data Model §Configuration] + - ✓ data-model.md §Configuration Constants: `DONE_STATUSES = {'Done', 'Closed', 'Resolved', 'Complete', 'Completed'}` - constant (config changeable) +- [x] CHK025 - Are Jira API version differences for changelog response format documented? [Gap] + - ✓ research.md §1: "Both Cloud (v3) and Server (v2) support this endpoint" - same structure +- [x] CHK026 - Is graceful degradation behavior when comments API fails specified? [Gap] + - ✓ spec.md §Assumptions: "Comments are already retrieved by the existing system (JiraClient.get_comments already implemented)" - existing error handling applies + +--- + +## Acceptance Criteria Quality + +- [x] CHK027 - Is "high" description_quality_score (>70) in US1 acceptance scenario objectively measurable? [Measurability, Spec §US1.4] + - ✓ spec.md §US1.4: "description_quality_score field is high (>70)" - explicit threshold +- [x] CHK028 - Is "high" cross_team_score in US5 acceptance scenario quantified with specific threshold? [Ambiguity, Spec §US5.1] + - ✓ spec.md §US5.1: "cross-team collaboration score is ≥75" - explicit threshold (was fixed in analysis phase) +- [x] CHK029 - Are success criteria SC-001 through SC-006 all independently testable? [Measurability, Spec §Success Criteria] + - ✓ All 6 SCs have measurable outcomes: time-based (SC-001), count-based (SC-002, SC-003), percentage (SC-004), performance (SC-005), boolean (SC-006) +- [x] CHK030 - Is SC-004 (80% identification rate) validation methodology defined? [Clarity, Spec §SC-004] + - ✓ spec.md §SC-004: "identifies at least 80% of issues with insufficient description (< 50 characters or no AC)" - criteria defined +- [x] CHK031 - Is SC-005 (200% performance threshold) baseline measurement specified? [Clarity, Spec §SC-005] + - ✓ spec.md §SC-005: "does not exceed 200% of current base export time" - baseline = current export without metrics + +--- + +## Scenario Coverage + +- [x] CHK032 - Are requirements defined for multi-project export scenarios? [Coverage, Gap] + - ✓ contracts/csv-schemas.md §2 Example: shows 2 projects (PROJ, DEV) in single file; ProjectMetrics aggregates by project_key +- [x] CHK033 - Are requirements for empty project (0 issues) export behavior specified? [Coverage, Edge Case] + - ✓ data-model.md §ProjectMetrics Validation: "Division by zero: Return 0.0 for ratios, None for averages" - handles empty projects +- [x] CHK034 - Are requirements for project with all unresolved issues specified for cycle_time aggregation? [Coverage, Edge Case] + - ✓ data-model.md §ProjectMetrics: "avg_cycle_time_days: float | None" + Validation: "None if no data points available" +- [x] CHK035 - Is the behavior for person with 0 resolved issues (avg_cycle_time = null) documented? [Coverage, Data Model] + - ✓ data-model.md §PersonMetrics: "avg_cycle_time_days: float | None" - None for 0 resolved issues +- [x] CHK036 - Are requirements defined for issue types not in standard set (Bug, Story, Task)? [Coverage, Spec §FR-019] + - ✓ spec.md §FR-019: "per issue_type" - aggregates all types; contracts/csv-schemas.md §4 Example shows Epic type + +--- + +## Non-Functional Requirements + +- [x] CHK037 - Is the performance target (≤200% of base export time) measurable with specific test methodology? [Measurability, Spec §SC-005] + - ✓ spec.md §SC-005: baseline is "current base export time"; test compares with/without metrics +- [x] CHK038 - Are memory constraints for large dataset processing specified? [Gap, Non-Functional] + - ✓ plan.md §Technical Context: "Handles typical Jira project sizes (hundreds to thousands of issues)" - streaming not required for expected scale +- [x] CHK039 - Is logging level/format for negative cycle_time warnings specified? [Gap, Spec §Edge Cases] + - ✓ constitution §V: "All errors MUST be logged with context (repo, operation, timestamp)" - follows standard logging +- [x] CHK040 - Are CSV file encoding requirements (UTF-8) explicitly documented? [Completeness, Contracts §Validation Rules] + - ✓ contracts/csv-schemas.md §Validation Rules: "Encoding: UTF-8" + +--- + +## Dependencies & Assumptions + +- [x] CHK041 - Is the assumption "JiraClient.get_comments already implemented" validated against current codebase? [Assumption, Spec §Assumptions] + - ✓ spec.md §Assumptions: documented as assumption; research.md §4 confirms "JiraClient.get_comments exists" +- [x] CHK042 - Is the assumption "creation and resolution dates always present" contradicted by edge case handling? [Conflict, Spec §Assumptions vs Edge Cases] + - ✓ No conflict: assumption is "for resolved issues"; edge case §CHK015 handles anomalies with warning+null +- [x] CHK043 - Are external dependencies (Jira API version, authentication) documented in plan.md? [Traceability] + - ✓ plan.md §Technical Context: "uses existing JiraClient auth"; research.md §1: "Both Cloud (v3) and Server (v2)" +- [x] CHK044 - Is backward compatibility requirement for existing CSV consumers explicitly stated? [Gap] + - ✓ research.md §6: "Adding columns at end preserves positional parsing"; plan.md §Constraints: "maintain backwards compatibility" + +--- + +## Traceability & Consistency + +- [x] CHK045 - Do all 23 functional requirements (FR-001 to FR-023) have corresponding acceptance scenarios? [Traceability] + - ✓ US1 covers FR-001 to FR-009 (issue metrics); US2 covers FR-010 to FR-014 (project); US3 covers FR-015 to FR-018 (person); US4 covers FR-019 to FR-021 (type); US5 covers FR-022 to FR-023 (reopen) +- [x] CHK046 - Is FR-023 (reopen_rate_percent) included in ProjectMetrics CSV schema? [Consistency, Contracts §2 vs Spec §FR-023] + - ✓ contracts/csv-schemas.md §2 Schema: "reopen_rate_percent | float | Reopen percentage | 5.00" +- [x] CHK047 - Are entity field names in data-model.md consistent with CSV column names in contracts? [Consistency] + - ✓ Verified: all field names match (e.g., cycle_time_days, description_quality_score, cross_team_score) +- [x] CHK048 - Is the "reopen_count" field present in both IssueMetrics and extended issue CSV schema? [Consistency] + - ✓ data-model.md §IssueMetrics: "reopen_count: int"; contracts/csv-schemas.md §1: "reopen_count | int | Times reopened" + +--- + +## Notes + +- Items marked [Gap] indicate potentially missing requirements +- Items marked [Ambiguity] indicate vague language needing quantification +- Items marked [Conflict] indicate potential contradictions between sections +- Items marked [Consistency] require cross-referencing multiple spec sections +- Focus: Algorithm accuracy, data integrity, API robustness, schema completeness diff --git a/specs/003-jira-quality-metrics/checklists/requirements.md b/specs/003-jira-quality-metrics/checklists/requirements.md new file mode 100644 index 0000000..7c27ccb --- /dev/null +++ b/specs/003-jira-quality-metrics/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: Jira Quality Metrics Export + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-11-28 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation +- Specification is ready for `/speckit.clarify` or `/speckit.plan` +- FR-022/FR-023 (reopen rate) are marked as "best-effort" based on API availability - documented in Assumptions +- Cross-team score uses comment authors as proxy since Jira doesn't expose team membership directly - documented in Assumptions diff --git a/specs/003-jira-quality-metrics/contracts/csv-schemas.md b/specs/003-jira-quality-metrics/contracts/csv-schemas.md new file mode 100644 index 0000000..010c759 --- /dev/null +++ b/specs/003-jira-quality-metrics/contracts/csv-schemas.md @@ -0,0 +1,149 @@ +# CSV Export Schemas: Jira Quality Metrics + +**Feature**: 003-jira-quality-metrics +**Date**: 2025-11-28 + +## 1. Extended Issue Export + +**File**: `jira_issues_export.csv` (modified) + +### Schema + +| Column | Type | Description | Example | +|--------|------|-------------|---------| +| key | string | Issue key | `PROJ-123` | +| summary | string | Issue title | `Fix login bug` | +| description | string | Issue description (plain text) | `Users cannot...` | +| status | string | Current status | `Done` | +| issue_type | string | Issue type | `Bug` | +| priority | string | Priority (empty if unset) | `High` | +| assignee | string | Assignee name (empty if unassigned) | `John Doe` | +| reporter | string | Reporter name | `Jane Smith` | +| created | ISO8601 | Creation timestamp | `2025-11-01T10:00:00+00:00` | +| updated | ISO8601 | Last update timestamp | `2025-11-28T14:30:00+00:00` | +| resolution_date | ISO8601 | Resolution timestamp (empty if unresolved) | `2025-11-15T16:00:00+00:00` | +| project_key | string | Parent project key | `PROJ` | +| **cycle_time_days** | float | Days from created to resolved (empty if unresolved) | `14.25` | +| **aging_days** | float | Days since created (empty if resolved) | `27.5` | +| **comments_count** | int | Number of comments | `5` | +| **description_quality_score** | int | Quality score 0-100 | `75` | +| **acceptance_criteria_present** | boolean | AC detected | `true` | +| **comment_velocity_hours** | float | Hours to first comment (empty if no comments) | `2.5` | +| **silent_issue** | boolean | No comments exist | `false` | +| **same_day_resolution** | boolean | Resolved same day as created | `false` | +| **cross_team_score** | int | Collaboration score 0-100 | `75` | +| **reopen_count** | int | Times reopened | `0` | + +**Notes**: +- Columns 1-12: Existing (unchanged order and format) +- Columns 13-22: NEW (appended) +- Boolean values: lowercase `true`/`false` +- Empty values: empty string (not `null` or `N/A`) +- Floats: 2 decimal places + +### Example + +```csv +key,summary,description,status,issue_type,priority,assignee,reporter,created,updated,resolution_date,project_key,cycle_time_days,aging_days,comments_count,description_quality_score,acceptance_criteria_present,comment_velocity_hours,silent_issue,same_day_resolution,cross_team_score,reopen_count +PROJ-123,Fix login bug,Users cannot login when...,Done,Bug,High,John Doe,Jane Smith,2025-11-01T10:00:00+00:00,2025-11-15T16:00:00+00:00,2025-11-15T16:00:00+00:00,PROJ,14.25,,5,75,true,2.50,false,false,75,0 +PROJ-124,Add dark mode,As a user I want...,In Progress,Story,Medium,Jane Smith,John Doe,2025-11-20T09:00:00+00:00,2025-11-28T11:00:00+00:00,,PROJ,,8.08,0,45,false,,true,false,0,0 +``` + +--- + +## 2. Project Metrics Summary + +**File**: `jira_project_metrics.csv` (new) + +### Schema + +| Column | Type | Description | Example | +|--------|------|-------------|---------| +| project_key | string | Project key | `PROJ` | +| total_issues | int | Total issues | `150` | +| resolved_count | int | Resolved issues | `120` | +| unresolved_count | int | Unresolved issues | `30` | +| avg_cycle_time_days | float | Mean cycle time | `7.50` | +| median_cycle_time_days | float | Median cycle time | `5.00` | +| bug_count | int | Bug issues | `45` | +| bug_ratio_percent | float | Bug percentage | `30.00` | +| same_day_resolution_rate_percent | float | Same-day resolution % | `15.00` | +| avg_description_quality | float | Mean quality score | `68.50` | +| silent_issues_ratio_percent | float | Silent issues % | `12.00` | +| avg_comments_per_issue | float | Mean comments | `3.20` | +| avg_comment_velocity_hours | float | Mean time to first comment | `4.50` | +| reopen_rate_percent | float | Reopen percentage | `5.00` | + +### Example + +```csv +project_key,total_issues,resolved_count,unresolved_count,avg_cycle_time_days,median_cycle_time_days,bug_count,bug_ratio_percent,same_day_resolution_rate_percent,avg_description_quality,silent_issues_ratio_percent,avg_comments_per_issue,avg_comment_velocity_hours,reopen_rate_percent +PROJ,150,120,30,7.50,5.00,45,30.00,15.00,68.50,12.00,3.20,4.50,5.00 +DEV,80,60,20,10.25,8.00,20,25.00,10.00,72.00,8.00,4.50,3.00,2.50 +``` + +--- + +## 3. Person Metrics Summary + +**File**: `jira_person_metrics.csv` (new) + +### Schema + +| Column | Type | Description | Example | +|--------|------|-------------|---------| +| assignee_name | string | Person's display name | `John Doe` | +| wip_count | int | Open assigned issues | `5` | +| resolved_count | int | Resolved assigned issues | `25` | +| total_assigned | int | Total assigned issues | `30` | +| avg_cycle_time_days | float | Mean cycle time | `6.75` | +| bug_count_assigned | int | Bugs assigned | `8` | + +### Example + +```csv +assignee_name,wip_count,resolved_count,total_assigned,avg_cycle_time_days,bug_count_assigned +John Doe,5,25,30,6.75,8 +Jane Smith,3,40,43,5.50,12 +Bob Wilson,8,15,23,9.00,5 +``` + +--- + +## 4. Type Metrics Summary + +**File**: `jira_type_metrics.csv` (new) + +### Schema + +| Column | Type | Description | Example | +|--------|------|-------------|---------| +| issue_type | string | Issue type name | `Bug` | +| count | int | Total issues of type | `45` | +| resolved_count | int | Resolved issues of type | `40` | +| avg_cycle_time_days | float | Mean cycle time | `4.50` | +| bug_resolution_time_avg | float | Bug-specific avg (empty for non-bugs) | `4.50` | + +### Example + +```csv +issue_type,count,resolved_count,avg_cycle_time_days,bug_resolution_time_avg +Bug,45,40,4.50,4.50 +Story,60,50,8.25, +Task,35,25,3.00, +Epic,10,5,45.00, +``` + +--- + +## Validation Rules (All Files) + +1. **Encoding**: UTF-8 +2. **Line endings**: LF (Unix style) +3. **Header**: First row contains column names +4. **Quoting**: RFC 4180 - quote fields containing commas, quotes, or newlines +5. **Empty values**: Empty string (no quotes needed) +6. **Booleans**: `true` or `false` (lowercase) +7. **Floats**: 2 decimal places, no thousands separator +8. **Dates**: ISO 8601 format with timezone +9. **Strings**: No length limit; newlines converted to spaces diff --git a/specs/003-jira-quality-metrics/data-model.md b/specs/003-jira-quality-metrics/data-model.md new file mode 100644 index 0000000..d3966b6 --- /dev/null +++ b/specs/003-jira-quality-metrics/data-model.md @@ -0,0 +1,236 @@ +# Data Model: Jira Quality Metrics Export + +**Feature**: 003-jira-quality-metrics +**Date**: 2025-11-28 + +## Entities + +### IssueMetrics + +Extended issue data with calculated quality metrics. Wraps existing `JiraIssue` dataclass. + +```python +@dataclass +class IssueMetrics: + """Calculated quality metrics for a single Jira issue. + + Attributes: + issue: Original JiraIssue data + cycle_time_days: Days from created to resolution (None if unresolved) + aging_days: Days from created to now (None if resolved) + comments_count: Total number of comments + description_quality_score: 0-100 score based on length/AC/formatting + acceptance_criteria_present: True if AC patterns detected + comment_velocity_hours: Hours from created to first comment (None if no comments) + silent_issue: True if no comments exist + same_day_resolution: True if resolved on creation date + cross_team_score: 0-100 based on distinct comment authors + reopen_count: Number of times reopened (0 if not trackable) + """ + issue: JiraIssue + cycle_time_days: float | None + aging_days: float | None + comments_count: int + description_quality_score: int + acceptance_criteria_present: bool + comment_velocity_hours: float | None + silent_issue: bool + same_day_resolution: bool + cross_team_score: int + reopen_count: int +``` + +**Validation Rules**: +- `cycle_time_days`: Must be >= 0 if present; negative values logged as warning and set to None +- `aging_days`: Must be >= 0; calculated only for unresolved issues +- `description_quality_score`: Must be 0-100 inclusive +- `cross_team_score`: Must be 0-100 inclusive +- `reopen_count`: Must be >= 0 + +**Derived From**: +- `issue.created`, `issue.resolution_date` → `cycle_time_days`, `same_day_resolution` +- `issue.created`, `datetime.now()` → `aging_days` +- `issue.description` → `description_quality_score`, `acceptance_criteria_present` +- `comments` list → `comments_count`, `comment_velocity_hours`, `silent_issue`, `cross_team_score` +- Changelog API → `reopen_count` + +--- + +### ProjectMetrics + +Aggregated metrics for a single project. + +```python +@dataclass +class ProjectMetrics: + """Aggregated quality metrics for a Jira project. + + Attributes: + project_key: Jira project key (e.g., PROJ) + total_issues: Total issues in export + resolved_count: Issues with resolution_date + unresolved_count: Issues without resolution_date + avg_cycle_time_days: Mean cycle time for resolved issues + median_cycle_time_days: Median cycle time for resolved issues + bug_count: Issues with type "Bug" + bug_ratio_percent: (bug_count / total_issues) * 100 + same_day_resolution_rate_percent: (same_day / resolved) * 100 + avg_description_quality: Mean description_quality_score + silent_issues_ratio_percent: (silent / total) * 100 + avg_comments_per_issue: Mean comments_count + avg_comment_velocity_hours: Mean comment_velocity for non-silent issues + reopen_rate_percent: (reopened / resolved) * 100 + """ + project_key: str + total_issues: int + resolved_count: int + unresolved_count: int + avg_cycle_time_days: float | None + median_cycle_time_days: float | None + bug_count: int + bug_ratio_percent: float + same_day_resolution_rate_percent: float + avg_description_quality: float + silent_issues_ratio_percent: float + avg_comments_per_issue: float + avg_comment_velocity_hours: float | None + reopen_rate_percent: float +``` + +**Validation Rules**: +- All percentage fields: 0.0-100.0 inclusive +- `avg_*` fields: None if no data points available +- Division by zero: Return 0.0 for ratios, None for averages + +--- + +### PersonMetrics + +Aggregated metrics for a single assignee. + +```python +@dataclass +class PersonMetrics: + """Aggregated quality metrics for a Jira assignee. + + Attributes: + assignee_name: Display name of assignee + wip_count: Count of open (unresolved) assigned issues + resolved_count: Count of resolved assigned issues + total_assigned: Total issues assigned + avg_cycle_time_days: Mean cycle time for their resolved issues + bug_count_assigned: Bugs assigned to this person + """ + assignee_name: str + wip_count: int + resolved_count: int + total_assigned: int + avg_cycle_time_days: float | None + bug_count_assigned: int +``` + +**Validation Rules**: +- `assignee_name`: Must not be empty string; issues without assignee are excluded +- `wip_count` + `resolved_count` = `total_assigned` + +--- + +### TypeMetrics + +Aggregated metrics per issue type. + +```python +@dataclass +class TypeMetrics: + """Aggregated quality metrics for a Jira issue type. + + Attributes: + issue_type: Issue type name (Bug, Story, Task, etc.) + count: Total issues of this type + resolved_count: Resolved issues of this type + avg_cycle_time_days: Mean cycle time for resolved issues of this type + bug_resolution_time_avg: Same as avg_cycle_time_days when type is Bug (None otherwise) + """ + issue_type: str + count: int + resolved_count: int + avg_cycle_time_days: float | None + bug_resolution_time_avg: float | None # Only populated for Bug type +``` + +**Validation Rules**: +- `issue_type`: Must not be empty string +- `bug_resolution_time_avg`: Only non-None when `issue_type == "Bug"` + +--- + +## Relationships + +``` +JiraIssue (existing) + │ + ├──[1:1]──> IssueMetrics (calculated wrapper) + │ │ + │ ├──[many:1]──> ProjectMetrics (aggregated by project_key) + │ │ + │ ├──[many:1]──> PersonMetrics (aggregated by assignee) + │ │ + │ └──[many:1]──> TypeMetrics (aggregated by issue_type) + │ + └──[1:many]──> JiraComment (existing) +``` + +--- + +## State Transitions + +Issues don't have explicit state machine in this feature. The `reopen_count` metric tracks transitions through resolution states: + +``` +Created → In Progress → Done (resolution_date set) + │ + ├──[reopen]──> In Progress (reopen_count++) + │ │ + │ └──> Done (resolution_date updated) + │ + └──[stays resolved] +``` + +**Reopen Detection Logic**: +1. Fetch changelog for issue +2. Find status field changes where `fromString` is in Done category +3. Check if `toString` is NOT in Done category +4. Each such transition = 1 reopen + +--- + +## Configuration Constants + +```python +# Description Quality Score weights (FR-004) +QUALITY_WEIGHT_LENGTH = 40 # Max points for description length +QUALITY_WEIGHT_AC = 40 # Points for acceptance criteria presence +QUALITY_WEIGHT_FORMAT = 20 # Max points for formatting (headers + lists) +QUALITY_LENGTH_THRESHOLD = 100 # Chars needed for full length score + +# Cross-team Score scale (FR-009) +CROSS_TEAM_SCALE = { + 1: 25, + 2: 50, + 3: 75, + 4: 90, + # 5+ authors = 100 +} + +# Acceptance Criteria detection patterns (FR-005) +AC_PATTERNS = [ + r'(?i)\bgiven\b.*\bwhen\b.*\bthen\b', + r'(?i)^#+\s*acceptance\s+criteria', + r'(?i)^ac\s*:', + r'(?i)acceptance\s+criteria\s*:', + r'^\s*[-*]\s*\[\s*[x ]?\s*\]', +] + +# Done status categories for reopen detection +DONE_STATUSES = {'Done', 'Closed', 'Resolved', 'Complete', 'Completed'} +``` diff --git a/specs/003-jira-quality-metrics/plan.md b/specs/003-jira-quality-metrics/plan.md new file mode 100644 index 0000000..d0a0ece --- /dev/null +++ b/specs/003-jira-quality-metrics/plan.md @@ -0,0 +1,87 @@ +# Implementation Plan: Jira Quality Metrics Export + +**Branch**: `003-jira-quality-metrics` | **Date**: 2025-11-28 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/003-jira-quality-metrics/spec.md` + +## Summary + +Enhance Jira export functionality with 12 quality metrics calculated at issue-level (cycle time, aging, description quality, comments count, acceptance criteria detection, comment velocity, silent issue flag, same-day resolution, cross-team score) plus aggregated metrics exported to 3 new CSV files (project, person, type summaries). Technical approach: extend existing `JiraExporter` and `JiraIssueAnalyzer` classes with new metrics calculator module. + +## Technical Context + +**Language/Version**: Python 3.9+ (per constitution, leveraging type hints) +**Primary Dependencies**: Standard library only (urllib, json, csv, os, re, datetime, statistics); optional: requests (already used in jira_client.py) +**Storage**: CSV files for export (same as existing GitHub exports) +**Testing**: pytest with fixtures (existing test infrastructure in tests/) +**Target Platform**: CLI tool (macOS/Linux) +**Project Type**: single (CLI application with modular structure) +**Performance Goals**: Export generation with metrics ≤200% of current base export time (SC-005) +**Constraints**: No new external dependencies; maintain backwards compatibility with existing export format +**Scale/Scope**: Handles typical Jira project sizes (hundreds to thousands of issues per project) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Modular Architecture | ✅ PASS | New metrics calculator in `analyzers/`, extends existing `exporters/jira_exporter.py` | +| II. Security First | ✅ PASS | No new credentials; uses existing JiraClient auth; no sensitive data in metrics | +| III. Test-Driven Development | ✅ PASS | Tests will be written before implementation; fixtures exist | +| IV. Configuration over Hardcoding | ✅ PASS | Metric thresholds (quality score weights) defined as constants | +| V. Graceful Error Handling | ✅ PASS | Edge cases documented; null handling specified in spec | + +**Code Quality Standards**: +- Type hints: REQUIRED (all new functions) +- Docstrings: REQUIRED (Google style) +- Max function length: 50 lines +- Max module length: 300 lines + +## Project Structure + +### Documentation (this feature) + +```text +specs/003-jira-quality-metrics/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output (CSV schemas) +└── tasks.md # Phase 2 output +``` + +### Source Code (repository root) + +```text +src/github_analyzer/ +├── analyzers/ +│ ├── jira_issues.py # Existing - basic stats +│ └── jira_metrics.py # NEW - quality metrics calculator +├── exporters/ +│ ├── jira_exporter.py # MODIFY - add metrics columns +│ └── jira_metrics_exporter.py # NEW - summary CSV exports +├── api/ +│ └── jira_client.py # Existing - may need changelog API +├── config/ +│ └── settings.py # MODIFY - add metrics config constants +└── core/ + └── exceptions.py # Existing - reuse for errors + +tests/ +├── unit/ +│ ├── analyzers/ +│ │ └── test_jira_metrics.py # NEW +│ └── exporters/ +│ └── test_jira_metrics_exporter.py # NEW +├── integration/ +│ └── test_jira_metrics_flow.py # NEW +└── fixtures/ + └── jira_responses.py # MODIFY - add metrics test data +``` + +**Structure Decision**: Single project structure following existing modular layout. New functionality added as separate modules (`jira_metrics.py`, `jira_metrics_exporter.py`) to maintain single responsibility and testability per constitution principle I. + +## Complexity Tracking + +No constitution violations. All complexity is justified by spec requirements. diff --git a/specs/003-jira-quality-metrics/quickstart.md b/specs/003-jira-quality-metrics/quickstart.md new file mode 100644 index 0000000..4afe7c4 --- /dev/null +++ b/specs/003-jira-quality-metrics/quickstart.md @@ -0,0 +1,163 @@ +# Quickstart: Jira Quality Metrics Export + +**Feature**: 003-jira-quality-metrics +**Date**: 2025-11-28 + +## Overview + +This feature adds quality metrics to Jira exports. After implementation, running the analyzer will: + +1. Export issues with 10 new calculated metric columns +2. Generate 3 new summary CSV files with aggregated metrics + +## Prerequisites + +- Python 3.9+ +- Existing Jira integration configured (`JIRA_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN`) +- `jira_projects.txt` file with project keys to analyze + +## Usage + +### Basic Export with Metrics + +```bash +# Run analysis with default settings (last 7 days) +python github_analyzer.py --jira + +# Run with custom date range +python github_analyzer.py --jira --days 30 + +# Specify output directory +python github_analyzer.py --jira --output ./reports +``` + +### Output Files + +After running, you'll find in the output directory: + +``` +output/ +├── jira_issues_export.csv # Issues with quality metrics (extended) +├── jira_comments_export.csv # Comments (unchanged) +├── jira_project_metrics.csv # NEW: Project-level summary +├── jira_person_metrics.csv # NEW: Per-person summary +└── jira_type_metrics.csv # NEW: Per-type summary +``` + +## New Metrics Explained + +### Issue-Level Metrics (per row in issues CSV) + +| Metric | What it measures | +|--------|------------------| +| `cycle_time_days` | Days from creation to resolution | +| `aging_days` | Days since creation (open issues only) | +| `comments_count` | Total comments on issue | +| `description_quality_score` | 0-100 quality rating | +| `acceptance_criteria_present` | Whether AC detected | +| `comment_velocity_hours` | Hours until first comment | +| `silent_issue` | True if no comments | +| `same_day_resolution` | Resolved same day as created | +| `cross_team_score` | 0-100 collaboration rating | +| `reopen_count` | Times issue was reopened | + +### Aggregated Metrics (summary CSVs) + +**Project metrics** (`jira_project_metrics.csv`): +- Average and median cycle time +- Bug ratio +- Same-day resolution rate +- Description quality average +- Silent issues ratio +- Reopen rate + +**Person metrics** (`jira_person_metrics.csv`): +- WIP (work in progress count) +- Resolved count +- Average cycle time +- Bugs assigned + +**Type metrics** (`jira_type_metrics.csv`): +- Count per type +- Resolved count +- Average cycle time +- Bug resolution time (for Bugs only) + +## Example: Identifying Problem Areas + +### Find silent issues (no collaboration) + +```bash +# Filter CSV for silent issues +grep ",true," output/jira_issues_export.csv | grep "silent_issue" +``` + +### Compare team workloads + +```bash +# View person metrics sorted by WIP +sort -t',' -k2 -nr output/jira_person_metrics.csv +``` + +### Identify slow-resolving issue types + +```bash +# View type metrics +cat output/jira_type_metrics.csv +``` + +## Configuration + +Metric calculation can be customized via environment variables (optional): + +```bash +# Quality score thresholds (defaults shown) +export JIRA_QUALITY_LENGTH_THRESHOLD=100 # Chars for full length score +export JIRA_QUALITY_WEIGHT_LENGTH=40 # Max points for length +export JIRA_QUALITY_WEIGHT_AC=40 # Points for AC presence +export JIRA_QUALITY_WEIGHT_FORMAT=20 # Max points for formatting +``` + +## Troubleshooting + +### Reopen count always 0 + +The changelog API requires specific permissions. If reopen tracking doesn't work: +- Verify user has "Browse project" permission +- Check if Jira admin has enabled changelog access +- This is expected behavior for some Jira configurations + +### Quality score seems off + +The score uses heuristics: +- 40% for description length (100+ chars = full score) +- 40% for acceptance criteria patterns +- 20% for formatting (headers, lists) + +Descriptions under 100 characters will have reduced scores. + +### Missing assignee in person metrics + +Issues without an assignee are excluded from person metrics. Check the issue export for `assignee` column. + +## API Reference + +### MetricsCalculator + +```python +from github_analyzer.analyzers.jira_metrics import MetricsCalculator + +calculator = MetricsCalculator() +issue_metrics = calculator.calculate_issue_metrics(issue, comments) +``` + +### MetricsExporter + +```python +from github_analyzer.exporters.jira_metrics_exporter import MetricsExporter + +exporter = MetricsExporter(output_dir="./output") +exporter.export_project_metrics(project_metrics_list) +exporter.export_person_metrics(person_metrics_list) +exporter.export_type_metrics(type_metrics_list) +``` diff --git a/specs/003-jira-quality-metrics/research.md b/specs/003-jira-quality-metrics/research.md new file mode 100644 index 0000000..d83c1b1 --- /dev/null +++ b/specs/003-jira-quality-metrics/research.md @@ -0,0 +1,211 @@ +# Research: Jira Quality Metrics Export + +**Feature**: 003-jira-quality-metrics +**Date**: 2025-11-28 + +## Research Tasks + +### 1. Jira Changelog API for Reopen Detection (FR-022) + +**Question**: How to detect issue reopens via Jira API? + +**Decision**: Use issue changelog endpoint `/rest/api/3/issue/{issueKey}/changelog` + +**Rationale**: +- Jira tracks all field changes in the changelog, including status transitions +- A "reopen" is detected when status changes FROM a "Done" category TO a non-Done category +- Both Cloud (v3) and Server (v2) support this endpoint +- Changelog includes: field name, from value, to value, timestamp, author + +**API Details**: +- Endpoint: `GET /rest/api/{version}/issue/{issueKey}/changelog` +- Response includes `values[]` array with `items[]` containing field changes +- Status changes have `field: "status"` with `fromString` and `toString` +- Pagination supported via `startAt` and `maxResults` + +**Alternatives Considered**: +- JQL for status changes: Rejected - JQL cannot query historical transitions +- Issue history field expansion: Rejected - requires explicit `expand=changelog` parameter which increases response size significantly + +**Implementation Note**: Changelog fetching is optional (best-effort per FR-022). If API returns 403 (permissions) or 404, reopen_count defaults to 0 without error. + +--- + +### 2. Acceptance Criteria Pattern Detection (FR-005) + +**Question**: What patterns reliably detect acceptance criteria in descriptions? + +**Decision**: Use regex pattern matching for common AC formats + +**Rationale**: +- Acceptance criteria follow recognizable patterns across teams +- Regex is fast and doesn't require NLP dependencies +- False positives are acceptable (better to over-detect than miss) + +**Patterns to Match**: +1. `Given ... When ... Then` (BDD/Gherkin style) - case insensitive +2. `AC:` or `Acceptance Criteria:` headers +3. `- [ ]` or `* [ ]` checkbox lists (markdown task lists) +4. `## Acceptance Criteria` heading +5. Numbered lists following AC header (e.g., `1.`, `2.`) + +**Regex Patterns**: +```python +AC_PATTERNS = [ + r'(?i)\bgiven\b.*\bwhen\b.*\bthen\b', # BDD + r'(?i)^#+\s*acceptance\s+criteria', # Markdown heading + r'(?i)^ac\s*:', # AC: prefix + r'(?i)acceptance\s+criteria\s*:', # Full label + r'^\s*[-*]\s*\[\s*[x ]?\s*\]', # Checkbox list +] +``` + +**Alternatives Considered**: +- NLP-based detection: Rejected - adds heavy dependency, overkill for this use case +- Keyword counting: Rejected - too many false positives + +--- + +### 3. Description Quality Score Algorithm (FR-004) + +**Question**: How to implement the balanced 40/40/20 weighting? + +**Decision**: Linear scoring with thresholds for each component + +**Rationale**: +- Simple to implement, test, and explain +- Thresholds based on typical issue description characteristics +- Score is 0-100 integer for easy comparison + +**Algorithm**: +```python +def calculate_description_quality(description: str, has_ac: bool) -> int: + score = 0 + + # Length component (40 points max) + # 0 chars = 0, 100+ chars = 40 points (linear) + length = len(description.strip()) + length_score = min(40, int(length * 40 / 100)) + score += length_score + + # AC presence component (40 points) + if has_ac: + score += 40 + + # Formatting component (20 points max) + # Check for headers (10 pts) and lists (10 pts) + has_headers = bool(re.search(r'^#+\s', description, re.MULTILINE)) + has_lists = bool(re.search(r'^\s*[-*]\s', description, re.MULTILINE)) + if has_headers: + score += 10 + if has_lists: + score += 10 + + return score +``` + +**Alternatives Considered**: +- Logarithmic scaling: Rejected - harder to explain, no clear benefit +- Word count instead of char count: Rejected - char count is simpler and sufficient + +--- + +### 4. Cross-Team Score Calculation (FR-009) + +**Question**: How to efficiently count distinct comment authors? + +**Decision**: Use set-based counting with diminishing returns scale + +**Rationale**: +- Comments are already fetched per issue (JiraClient.get_comments exists) +- Set gives O(1) lookup for unique authors +- Diminishing scale rewards collaboration without requiring large teams + +**Algorithm**: +```python +CROSS_TEAM_SCALE = {1: 25, 2: 50, 3: 75, 4: 90} # 5+ = 100 + +def calculate_cross_team_score(comments: list[JiraComment]) -> int: + if not comments: + return 0 + unique_authors = len({c.author for c in comments}) + return CROSS_TEAM_SCALE.get(unique_authors, 100) +``` + +**Alternatives Considered**: +- Include reporter in count: Rejected - reporter often doesn't engage in comments +- Weight by comment count per author: Rejected - adds complexity without value + +--- + +### 5. Statistics Calculation (Median, Average) + +**Question**: Best approach for calculating statistical aggregates? + +**Decision**: Use Python `statistics` module (stdlib) + +**Rationale**: +- `statistics.mean()` and `statistics.median()` handle edge cases properly +- No external dependencies needed +- Handles empty lists gracefully with clear exceptions + +**Implementation**: +```python +from statistics import mean, median + +def safe_mean(values: list[float]) -> float | None: + return mean(values) if values else None + +def safe_median(values: list[float]) -> float | None: + return median(values) if values else None +``` + +**Alternatives Considered**: +- numpy: Rejected - adds heavy dependency for simple stats +- Manual implementation: Rejected - reinventing the wheel, error-prone + +--- + +### 6. CSV Export Structure + +**Question**: How to extend existing CSV export without breaking compatibility? + +**Decision**: Add new columns to existing export + generate new summary files + +**Rationale**: +- Existing consumers may parse by column name, not position +- Adding columns at end preserves positional parsing +- Summary files are new, no compatibility concerns + +**Extended Issue Columns** (appended to existing): +``` +cycle_time_days, aging_days, comments_count, description_quality_score, +acceptance_criteria_present, comment_velocity_hours, silent_issue, +same_day_resolution, cross_team_score, reopen_count +``` + +**New Files**: +- `jira_project_metrics.csv` +- `jira_person_metrics.csv` +- `jira_type_metrics.csv` + +**Alternatives Considered**: +- Separate metrics-only CSV: Rejected - users need metrics alongside issue data +- JSON output: Rejected - spec requires CSV format consistency + +--- + +## Summary + +All technical unknowns have been resolved: + +| Area | Decision | Risk Level | +|------|----------|------------| +| Reopen detection | Changelog API (best-effort) | Low - graceful fallback | +| AC detection | Regex patterns | Low - false positives acceptable | +| Quality score | Linear 40/40/20 algorithm | Low - simple, testable | +| Cross-team score | Set-based + diminishing scale | Low - deterministic | +| Statistics | stdlib statistics module | None - battle-tested | +| CSV structure | Extend + new files | Low - backwards compatible | + +No NEEDS CLARIFICATION items remain. Ready for Phase 1. diff --git a/specs/003-jira-quality-metrics/spec.md b/specs/003-jira-quality-metrics/spec.md new file mode 100644 index 0000000..a1c624b --- /dev/null +++ b/specs/003-jira-quality-metrics/spec.md @@ -0,0 +1,174 @@ +# Feature Specification: Jira Quality Metrics Export + +**Feature Branch**: `003-jira-quality-metrics` +**Created**: 2025-11-28 +**Status**: Draft +**Input**: User description: "Enhance Jira export files with quality metrics: Cycle Time, Bug Ratio, Description Quality Score, Comments per Issue, Aging, Same-day resolution rate, Reopen rate, Comment velocity, WIP per person, Acceptance Criteria presence, Cross-team collaboration score, Silent issues ratio" + +## Clarifications + +### Session 2025-11-28 + +- Q: What weighting should description_quality_score use? → A: Balanced: 40% length (>100 chars=full), 40% AC presence, 20% formatting (headers/lists) +- Q: How should cross_team_score map to author count? → A: Diminishing scale: 1 author=25, 2=50, 3=75, 4=90, 5+=100 + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Individual Issue Quality Assessment (Priority: P1) + +As a team lead, I want to see individual quality metrics for each exported issue, so I can quickly identify problematic issues and understand where to intervene. + +**Why this priority**: This is the foundation for all other analyses. Without issue-level metrics, meaningful aggregations cannot be calculated. + +**Independent Test**: Can be tested by exporting a set of issues and verifying that each CSV row contains calculated metrics (cycle time, aging, comments count, description quality score). + +**Acceptance Scenarios**: + +1. **Given** a resolved issue with creation and resolution dates, **When** I export issues, **Then** the CSV contains the cycle time in days for that issue +2. **Given** an open issue created 30 days ago, **When** I export issues, **Then** the CSV shows aging of 30 days +3. **Given** an issue with 5 comments, **When** I export issues, **Then** the CSV shows 5 in the comments_count field +4. **Given** an issue with 500-character description and acceptance criteria, **When** I export issues, **Then** the description_quality_score field is high (>70) +5. **Given** an issue without description, **When** I export issues, **Then** the description_quality_score field is 0 + +--- + +### User Story 2 - Project-Level Aggregated Metrics (Priority: P2) + +As a project manager, I want to see aggregated metrics per project, so I can compare quality and performance across different projects. + +**Why this priority**: Aggregated metrics enable strategic decisions at portfolio level, but require individual metrics (P1) first. + +**Independent Test**: Can be tested by generating a project summary file and verifying it contains aggregated metrics (avg cycle time, bug ratio, resolution rate). + +**Acceptance Scenarios**: + +1. **Given** a project with 10 resolved issues, **When** I export data, **Then** I get a summary file with the project's average cycle time +2. **Given** a project with 20 issues including 5 Bugs, **When** I export data, **Then** the summary shows 25% bug ratio +3. **Given** a project with 8 same-day resolved issues out of 10 total resolved, **When** I export data, **Then** the same-day resolution rate is 80% + +--- + +### User Story 3 - Team Member Performance Metrics (Priority: P2) + +As a team lead, I want to see aggregated metrics per person (assignee), so I can understand workload distribution and individual performance. + +**Why this priority**: Complementary to P2, enables analysis of work distribution within the team. + +**Independent Test**: Can be tested by verifying the person metrics file contains WIP, avg cycle time, and issue count for each assignee. + +**Acceptance Scenarios**: + +1. **Given** a user with 5 open assigned issues, **When** I export data, **Then** the WIP (Work In Progress) for that person is 5 +2. **Given** a user who resolved 10 issues with average time of 3 days, **When** I export data, **Then** their avg cycle time is 3 days +3. **Given** issues assigned to multiple different people, **When** I export data, **Then** each person appears with their own metrics in the file + +--- + +### User Story 4 - Issue Type Performance Analysis (Priority: P3) + +As a product owner, I want to see aggregated metrics per issue type (Bug, Story, Task), so I can understand where the team spends most time. + +**Why this priority**: Complementary analysis that helps optimize processes by work type. + +**Independent Test**: Can be tested by verifying the type summary contains distinct metrics for Bug, Story, Task, etc. + +**Acceptance Scenarios**: + +1. **Given** 10 Bugs with 2-day average cycle time and 10 Stories with 5-day average cycle time, **When** I export data, **Then** the summary shows these distinct averages per type +2. **Given** Bugs with variable resolution time, **When** I export data, **Then** I get the average Bug Resolution Time + +--- + +### User Story 5 - Collaboration and Communication Metrics (Priority: P3) + +As a team lead, I want metrics on collaboration and communication within issues, so I can identify silos or communication problems. + +**Why this priority**: Advanced metrics that require the foundational metrics already implemented. + +**Independent Test**: Can be tested by verifying issues show cross-team collaboration score and comment velocity. + +**Acceptance Scenarios**: + +1. **Given** an issue with comments from 3 different people, **When** I export data, **Then** the cross-team collaboration score is ≥75 (per FR-009 diminishing scale) +2. **Given** an issue created Monday with first comment Wednesday, **When** I export data, **Then** the comment velocity (first comment) is 48 hours +3. **Given** an issue without comments, **When** I export data, **Then** it is marked as "silent issue" (silent_issue = true) + +--- + +### Edge Cases + +- What happens when an issue has no assignee? → WIP is not counted for anyone, person metrics ignore the issue +- How are issues with negative cycle time handled (data error)? → System logs a warning and sets cycle_time to null +- What happens if an issue was reopened? → If trackable from history, increments reopen_count +- How is description quality score calculated for ADF format descriptions? → Convert to plain text first, then analyze +- What happens with issues without comments? → comments_count = 0, comment_velocity = null, silent_issue = true +- How is division by zero handled in ratios? → Return 0% or null with appropriate note + +## Requirements *(mandatory)* + +### Functional Requirements + +**Issue-Level Metrics (extension of existing export):** + +- **FR-001**: System MUST calculate **cycle_time** (days between created and resolution_date) for each resolved issue +- **FR-002**: System MUST calculate **aging** (days between created and today) for each open issue +- **FR-003**: System MUST count the **comments_count** for each issue +- **FR-004**: System MUST calculate a **description_quality_score** (0-100) using balanced weighting: 40% length (>100 chars = full score), 40% acceptance criteria presence, 20% formatting (headers/lists detected) +- **FR-005**: System MUST identify **acceptance_criteria** presence (boolean) by searching for common patterns (Given/When/Then, AC:, Acceptance Criteria, checkbox lists) +- **FR-006**: System MUST calculate **comment_velocity_hours** (hours from created to first comment) for each issue with comments +- **FR-007**: System MUST mark each issue as **silent_issue** (boolean) if it has no comments +- **FR-008**: System MUST indicate if issue is **same_day_resolution** (boolean) if resolved on the same day as creation +- **FR-009**: System MUST calculate a **cross_team_score** (0-100) using diminishing scale based on distinct comment authors: 1 author=25, 2=50, 3=75, 4=90, 5+=100 + +**Project-Level Aggregated Metrics:** + +- **FR-010**: System MUST generate a **jira_project_metrics.csv** file with aggregated metrics per project +- **FR-011**: Project metrics MUST include: avg_cycle_time, median_cycle_time, bug_count, total_issues, bug_ratio_percent +- **FR-012**: Project metrics MUST include: resolved_count, same_day_resolution_rate_percent +- **FR-013**: Project metrics MUST include: avg_description_quality, silent_issues_ratio_percent +- **FR-014**: Project metrics MUST include: avg_comments_per_issue, avg_comment_velocity_hours + +**Person-Level Aggregated Metrics:** + +- **FR-015**: System MUST generate a **jira_person_metrics.csv** file with aggregated metrics per assignee +- **FR-016**: Person metrics MUST include: assignee_name, wip_count (open assigned issues) +- **FR-017**: Person metrics MUST include: resolved_count, avg_cycle_time_days +- **FR-018**: Person metrics MUST include: total_assigned, bug_count_assigned + +**Type-Level Aggregated Metrics:** + +- **FR-019**: System MUST generate a **jira_type_metrics.csv** file with aggregated metrics per issue_type +- **FR-020**: Type metrics MUST include: issue_type, count, resolved_count, avg_cycle_time_days +- **FR-021**: For Bugs specifically, MUST calculate **bug_resolution_time_avg** separately + +**Reopen Rate (if trackable):** + +- **FR-022**: If transition history is available via API, system MUST calculate **reopen_count** per issue +- **FR-023**: System MUST calculate **reopen_rate_percent** in aggregated metrics (reopened issues / resolved issues * 100) + +### Key Entities + +- **IssueMetrics**: Extension of issue data with calculated fields (cycle_time, aging, comments_count, description_quality_score, acceptance_criteria_present, comment_velocity_hours, silent_issue, same_day_resolution, cross_team_score, reopen_count) +- **ProjectMetrics**: Aggregation by project_key with averages, medians, ratios and counts +- **PersonMetrics**: Aggregation by assignee with WIP, performance and volumes +- **TypeMetrics**: Aggregation by issue_type with cycle time and volumes + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can identify "silent" issues (without comments) in less than 30 seconds by opening the CSV +- **SC-002**: Users can compare performance across 5+ projects in a single view (summary file) +- **SC-003**: Average cycle time is calculated with day precision for 100% of resolved issues +- **SC-004**: Description quality score identifies at least 80% of issues with insufficient description (< 50 characters or no AC) +- **SC-005**: Export generation time with metrics does not exceed 200% of current base export time +- **SC-006**: Users can see each team member's WIP to plan work allocation + +## Assumptions + +- Comments are already retrieved by the existing system (JiraClient.get_comments already implemented) +- Creation and resolution dates are always present and valid for resolved issues +- Acceptance criteria identification is based on common text patterns (Given/When/Then, checkbox markdown, keyword "Acceptance Criteria") +- Cross-team score is based on comment author diversity, not an explicit team field +- Transition history for reopen rate may not be available in all Jira configurations (FR-022/FR-023 are best-effort) +- Description quality score is a simple heuristic, not advanced semantic analysis diff --git a/specs/003-jira-quality-metrics/tasks.md b/specs/003-jira-quality-metrics/tasks.md new file mode 100644 index 0000000..010c969 --- /dev/null +++ b/specs/003-jira-quality-metrics/tasks.md @@ -0,0 +1,287 @@ +# Tasks: Jira Quality Metrics Export + +**Input**: Design documents from `/specs/003-jira-quality-metrics/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/ + +**Tests**: TDD approach per constitution principle III - tests written before implementation. + +**Organization**: Tasks grouped by user story to enable independent implementation and testing. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1-US5) +- Include exact file paths in descriptions + +## Path Conventions + +- **Single project**: `src/github_analyzer/`, `tests/` at repository root +- Paths follow existing modular structure per plan.md + +--- + +## Phase 1: Setup + +**Purpose**: Configuration constants and test fixtures + +- [x] T001 Add metrics configuration constants to src/github_analyzer/config/settings.py (QUALITY_WEIGHT_LENGTH=40, QUALITY_WEIGHT_AC=40, QUALITY_WEIGHT_FORMAT=20, QUALITY_LENGTH_THRESHOLD=100, CROSS_TEAM_SCALE, AC_PATTERNS, DONE_STATUSES) +- [x] T002 [P] Add metrics test fixtures to tests/fixtures/jira_responses.py (sample issues with various descriptions, comments, resolution dates for testing all metrics) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core dataclasses and metrics calculator that ALL user stories depend on + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [x] T003 Create IssueMetrics dataclass in src/github_analyzer/analyzers/jira_metrics.py per data-model.md +- [x] T004 Create ProjectMetrics dataclass in src/github_analyzer/analyzers/jira_metrics.py per data-model.md +- [x] T005 [P] Create PersonMetrics dataclass in src/github_analyzer/analyzers/jira_metrics.py per data-model.md +- [x] T006 [P] Create TypeMetrics dataclass in src/github_analyzer/analyzers/jira_metrics.py per data-model.md + +**Checkpoint**: Foundation ready - user story implementation can now begin + +--- + +## Phase 3: User Story 1 - Individual Issue Quality Assessment (Priority: P1) 🎯 MVP + +**Goal**: Calculate and export 10 quality metrics for each individual issue in jira_issues_export.csv + +**Independent Test**: Export issues and verify CSV contains all new metric columns with correct values + +### Tests for User Story 1 + +- [x] T007 [P] [US1] Write unit tests for cycle_time calculation in tests/unit/analyzers/test_jira_metrics.py (resolved issue, open issue, negative time edge case) +- [x] T008 [P] [US1] Write unit tests for aging calculation in tests/unit/analyzers/test_jira_metrics.py (open issue only, resolved returns None) +- [x] T009 [P] [US1] Write unit tests for description_quality_score in tests/unit/analyzers/test_jira_metrics.py (empty, short, long, with AC, with formatting) +- [x] T010 [P] [US1] Write unit tests for acceptance_criteria detection in tests/unit/analyzers/test_jira_metrics.py (Given/When/Then, AC:, checkbox, none) +- [x] T011 [P] [US1] Write unit tests for comments_count and silent_issue in tests/unit/analyzers/test_jira_metrics.py +- [x] T012 [P] [US1] Write unit tests for same_day_resolution in tests/unit/analyzers/test_jira_metrics.py +- [x] T013 [P] [US1] Write unit tests for cross_team_score calculation in tests/unit/analyzers/test_jira_metrics.py (1-5+ authors, diminishing scale per FR-009) +- [x] T014 [P] [US1] Write unit tests for comment_velocity_hours in tests/unit/analyzers/test_jira_metrics.py (FR-006) +- [x] T015 [P] [US1] Write unit test for extended CSV export columns in tests/unit/exporters/test_jira_exporter.py + +### Implementation for User Story 1 + +- [x] T016 [US1] Implement calculate_cycle_time() function in src/github_analyzer/analyzers/jira_metrics.py (FR-001) +- [x] T017 [US1] Implement calculate_aging() function in src/github_analyzer/analyzers/jira_metrics.py (FR-002) +- [x] T018 [US1] Implement detect_acceptance_criteria() function in src/github_analyzer/analyzers/jira_metrics.py (FR-005) +- [x] T019 [US1] Implement calculate_description_quality() function in src/github_analyzer/analyzers/jira_metrics.py (FR-004) +- [x] T020 [US1] Implement calculate_comment_metrics() function in src/github_analyzer/analyzers/jira_metrics.py (FR-003, FR-006, FR-007) +- [x] T021 [US1] Implement calculate_same_day_resolution() function in src/github_analyzer/analyzers/jira_metrics.py (FR-008) +- [x] T022 [US1] Implement calculate_cross_team_score() function in src/github_analyzer/analyzers/jira_metrics.py (FR-009) +- [x] T023 [US1] Implement MetricsCalculator.calculate_issue_metrics() method combining all individual metrics in src/github_analyzer/analyzers/jira_metrics.py +- [x] T024 [US1] Extend ISSUE_COLUMNS tuple in src/github_analyzer/exporters/jira_exporter.py with 10 new metric columns per contracts/csv-schemas.md +- [x] T025 [US1] Modify JiraExporter.export_issues() in src/github_analyzer/exporters/jira_exporter.py to accept IssueMetrics and write extended columns + +**Checkpoint**: User Story 1 complete - issues export with all individual metrics + +--- + +## Phase 4: User Story 2 - Project-Level Aggregated Metrics (Priority: P2) + +**Goal**: Generate jira_project_metrics.csv with aggregated metrics per project + +**Independent Test**: Export project summary and verify CSV contains avg/median cycle time, bug ratio, resolution rates + +### Tests for User Story 2 + +- [x] T026 [P] [US2] Write unit tests for project aggregation in tests/unit/analyzers/test_jira_metrics.py (avg, median cycle time, bug ratio, silent ratio) +- [x] T027 [P] [US2] Write unit tests for project metrics CSV export in tests/unit/exporters/test_jira_metrics_exporter.py + +### Implementation for User Story 2 + +- [x] T028 [US2] Implement MetricsCalculator.aggregate_project_metrics() in src/github_analyzer/analyzers/jira_metrics.py (FR-010 to FR-014) +- [x] T029 [US2] Create JiraMetricsExporter class in src/github_analyzer/exporters/jira_metrics_exporter.py +- [x] T030 [US2] Implement JiraMetricsExporter.export_project_metrics() in src/github_analyzer/exporters/jira_metrics_exporter.py per contracts/csv-schemas.md + +**Checkpoint**: User Story 2 complete - project summary CSV available + +--- + +## Phase 5: User Story 3 - Team Member Performance Metrics (Priority: P2) + +**Goal**: Generate jira_person_metrics.csv with WIP, cycle time, and counts per assignee + +**Independent Test**: Export person summary and verify CSV contains correct WIP and performance metrics per assignee + +### Tests for User Story 3 + +- [x] T031 [P] [US3] Write unit tests for person aggregation in tests/unit/analyzers/test_jira_metrics.py (WIP count, resolved count, avg cycle time, explicitly test unassigned issues are excluded per edge case) +- [x] T032 [P] [US3] Write unit tests for person metrics CSV export in tests/unit/exporters/test_jira_metrics_exporter.py + +### Implementation for User Story 3 + +- [x] T033 [US3] Implement MetricsCalculator.aggregate_person_metrics() in src/github_analyzer/analyzers/jira_metrics.py (FR-015 to FR-018) +- [x] T034 [US3] Implement JiraMetricsExporter.export_person_metrics() in src/github_analyzer/exporters/jira_metrics_exporter.py per contracts/csv-schemas.md + +**Checkpoint**: User Story 3 complete - person summary CSV available + +--- + +## Phase 6: User Story 4 - Issue Type Performance Analysis (Priority: P3) + +**Goal**: Generate jira_type_metrics.csv with cycle time and counts per issue type + +**Independent Test**: Export type summary and verify CSV shows distinct metrics for Bug, Story, Task + +### Tests for User Story 4 + +- [x] T035 [P] [US4] Write unit tests for type aggregation in tests/unit/analyzers/test_jira_metrics.py (per-type counts, avg cycle time, bug_resolution_time_avg only for Bug) +- [x] T036 [P] [US4] Write unit tests for type metrics CSV export in tests/unit/exporters/test_jira_metrics_exporter.py + +### Implementation for User Story 4 + +- [x] T037 [US4] Implement MetricsCalculator.aggregate_type_metrics() in src/github_analyzer/analyzers/jira_metrics.py (FR-019 to FR-021) +- [x] T038 [US4] Implement JiraMetricsExporter.export_type_metrics() in src/github_analyzer/exporters/jira_metrics_exporter.py per contracts/csv-schemas.md + +**Checkpoint**: User Story 4 complete - type summary CSV available + +--- + +## Phase 7: User Story 5 - Reopen Tracking and Aggregation Enhancement (Priority: P3) + +**Goal**: Add reopen_count per issue via changelog API, and include reopen_rate_percent in project aggregation (FR-022, FR-023) + +**Independent Test**: Export issues with reopen_count populated (best-effort), verify project CSV includes reopen_rate_percent + +### Tests for User Story 5 + +- [x] T039 [P] [US5] Write unit tests for reopen detection in tests/unit/analyzers/test_jira_metrics.py (mock changelog response, status transitions from Done to non-Done) +- [x] T040 [P] [US5] Write unit tests for changelog API error handling in tests/unit/api/test_jira_client.py (mock 403/404 responses, verify graceful degradation returns 0) +- [x] T041 [P] [US5] Write unit tests for reopen_rate_percent in project aggregation in tests/unit/analyzers/test_jira_metrics.py + +### Implementation for User Story 5 + +- [x] T042 [US5] Add get_issue_changelog() method to JiraClient in src/github_analyzer/api/jira_client.py (best-effort, graceful 403/404 handling per spec assumptions) +- [x] T043 [US5] Implement detect_reopens() function in src/github_analyzer/analyzers/jira_metrics.py (FR-022) +- [x] T044 [US5] Update MetricsCalculator.calculate_issue_metrics() to include reopen_count in src/github_analyzer/analyzers/jira_metrics.py +- [x] T045 [US5] Add reopen_rate_percent to MetricsCalculator.aggregate_project_metrics() in src/github_analyzer/analyzers/jira_metrics.py (FR-023) +- [x] T046 [US5] Ensure reopen_rate_percent column is exported in JiraMetricsExporter.export_project_metrics() per contracts/csv-schemas.md + +**Checkpoint**: User Story 5 complete - reopen tracking functional with graceful degradation + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +**Purpose**: Integration, CLI wiring, and final validation + +- [x] T047 Create integration test for full metrics flow in tests/integration/test_jira_metrics_flow.py +- [x] T048 Wire metrics calculation and export into CLI in src/github_analyzer/cli/main.py (add --jira-metrics flag or auto-include with --jira) +- [x] T049 Run quickstart.md validation - verify all documented commands work +- [x] T050 [P] Run ruff check and fix any linting issues +- [x] T051 [P] Verify all tests pass with pytest tests/ -v + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup - BLOCKS all user stories +- **User Stories (Phase 3-7)**: All depend on Foundational completion + - US1 (P1): No story dependencies - MVP standalone (includes all issue-level metrics: cycle_time, aging, description_quality, comments, cross_team_score, comment_velocity, same_day_resolution) + - US2 (P2): Requires US1 IssueMetrics for aggregation input + - US3 (P2): Requires US1 IssueMetrics for aggregation input + - US4 (P3): Requires US1 IssueMetrics for aggregation input + - US5 (P3): Adds reopen tracking (changelog API) and reopen_rate_percent to aggregations +- **Polish (Phase 8)**: Depends on all user stories + +### User Story Dependencies + +``` +Phase 1: Setup + ↓ +Phase 2: Foundational (dataclasses) + ↓ +Phase 3: US1 - Issue Metrics [MVP] ← Can stop here for MVP delivery + ↓ +Phase 4: US2 - Project Aggregation ─┐ +Phase 5: US3 - Person Aggregation ─┼── Can run in parallel (different exports) +Phase 6: US4 - Type Aggregation ─┤ +Phase 7: US5 - Reopen Tracking ─┘ + ↓ +Phase 8: Polish & Integration +``` + +### Within Each User Story + +1. Tests MUST be written and FAIL before implementation +2. Individual metric functions before composite calculator +3. Calculator before exporter modifications +4. Story complete before moving to next priority + +### Parallel Opportunities + +Within Phase 2 (Foundational): +- T004, T005, T006 can run in parallel (different dataclasses) + +Within US1 Tests: +- T007-T015 can ALL run in parallel (different test functions) + +Within US1 Implementation: +- T016-T022 can run in parallel (independent functions) +- T023-T025 must be sequential (dependencies) + +Within US2-US5: +- Test tasks marked [P] can run in parallel +- Aggregation phases US2-US4 can run in parallel once US1 complete + +--- + +## Parallel Example: User Story 1 Tests + +```bash +# Launch all US1 tests together (T007-T015): +Task: "Write unit tests for cycle_time calculation" +Task: "Write unit tests for aging calculation" +Task: "Write unit tests for description_quality_score" +Task: "Write unit tests for acceptance_criteria detection" +Task: "Write unit tests for comments_count and silent_issue" +Task: "Write unit tests for same_day_resolution" +Task: "Write unit tests for cross_team_score" +Task: "Write unit tests for comment_velocity_hours" +Task: "Write unit test for extended CSV export columns" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (T001-T002) +2. Complete Phase 2: Foundational (T003-T006) +3. Complete Phase 3: User Story 1 (T007-T025) +4. **STOP and VALIDATE**: Export issues, verify all 10 new columns present +5. Deploy/demo if ready - MVP complete! + +### Incremental Delivery + +1. Setup + Foundational → Foundation ready +2. Add US1 → Test → **MVP delivered** (issue-level metrics including cross_team_score, comment_velocity) +3. Add US2 → Test → Project summary available +4. Add US3 → Test → Person summary available +5. Add US4 → Test → Type summary available +6. Add US5 → Test → Reopen tracking complete +7. Polish → Final validation → **Full feature complete** + +### Single Developer Strategy + +Follow phases sequentially: +- Phase 1 → Phase 2 → Phase 3 (MVP) → Phase 4 → Phase 5 → Phase 6 → Phase 7 → Phase 8 + +--- + +## Notes + +- [P] tasks = different files, no dependencies on incomplete tasks +- [Story] label maps task to specific user story (US1-US5) +- Each user story can be delivered independently after US1 (MVP) +- Constitution requires TDD: write failing tests first +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- Total tasks: 51 diff --git a/src/github_analyzer/analyzers/jira_metrics.py b/src/github_analyzer/analyzers/jira_metrics.py new file mode 100644 index 0000000..0047ab5 --- /dev/null +++ b/src/github_analyzer/analyzers/jira_metrics.py @@ -0,0 +1,641 @@ +"""Jira quality metrics calculation module. + +This module provides dataclasses and functions for calculating +quality metrics on Jira issues, including cycle time, description +quality, collaboration scores, and aggregated metrics. + +Implements: FR-001 to FR-023 per spec.md +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from statistics import mean, median +from typing import TYPE_CHECKING + +from src.github_analyzer.config.settings import ( + AC_PATTERNS, + CROSS_TEAM_SCALE, + DONE_STATUSES, + QUALITY_LENGTH_THRESHOLD, + QUALITY_WEIGHT_AC, + QUALITY_WEIGHT_FORMAT, + QUALITY_WEIGHT_LENGTH, +) + +if TYPE_CHECKING: + from src.github_analyzer.api.jira_client import JiraComment, JiraIssue + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Dataclasses (T003-T006) +# ============================================================================= + + +@dataclass +class IssueMetrics: + """Calculated quality metrics for a single Jira issue. + + Attributes: + issue: Original JiraIssue data. + cycle_time_days: Days from created to resolution (None if unresolved). + aging_days: Days from created to now (None if resolved). + comments_count: Total number of comments. + description_quality_score: 0-100 score based on length/AC/formatting. + acceptance_criteria_present: True if AC patterns detected. + comment_velocity_hours: Hours from created to first comment (None if no comments). + silent_issue: True if no comments exist. + same_day_resolution: True if resolved on creation date. + cross_team_score: 0-100 based on distinct comment authors. + reopen_count: Number of times reopened (0 if not trackable). + """ + + issue: JiraIssue + cycle_time_days: float | None + aging_days: float | None + comments_count: int + description_quality_score: int + acceptance_criteria_present: bool + comment_velocity_hours: float | None + silent_issue: bool + same_day_resolution: bool + cross_team_score: int + reopen_count: int + + +@dataclass +class ProjectMetrics: + """Aggregated quality metrics for a Jira project. + + Attributes: + project_key: Jira project key (e.g., PROJ). + total_issues: Total issues in export. + resolved_count: Issues with resolution_date. + unresolved_count: Issues without resolution_date. + avg_cycle_time_days: Mean cycle time for resolved issues. + median_cycle_time_days: Median cycle time for resolved issues. + bug_count: Issues with type "Bug". + bug_ratio_percent: (bug_count / total_issues) * 100. + same_day_resolution_rate_percent: (same_day / resolved) * 100. + avg_description_quality: Mean description_quality_score. + silent_issues_ratio_percent: (silent / total) * 100. + avg_comments_per_issue: Mean comments_count. + avg_comment_velocity_hours: Mean comment_velocity for non-silent issues. + reopen_rate_percent: (reopened / resolved) * 100. + """ + + project_key: str + total_issues: int + resolved_count: int + unresolved_count: int + avg_cycle_time_days: float | None + median_cycle_time_days: float | None + bug_count: int + bug_ratio_percent: float + same_day_resolution_rate_percent: float + avg_description_quality: float + silent_issues_ratio_percent: float + avg_comments_per_issue: float + avg_comment_velocity_hours: float | None + reopen_rate_percent: float + + +@dataclass +class PersonMetrics: + """Aggregated quality metrics for a Jira assignee. + + Attributes: + assignee_name: Display name of assignee. + wip_count: Count of open (unresolved) assigned issues. + resolved_count: Count of resolved assigned issues. + total_assigned: Total issues assigned. + avg_cycle_time_days: Mean cycle time for their resolved issues. + bug_count_assigned: Bugs assigned to this person. + """ + + assignee_name: str + wip_count: int + resolved_count: int + total_assigned: int + avg_cycle_time_days: float | None + bug_count_assigned: int + + +@dataclass +class TypeMetrics: + """Aggregated quality metrics for a Jira issue type. + + Attributes: + issue_type: Issue type name (Bug, Story, Task, etc.). + count: Total issues of this type. + resolved_count: Resolved issues of this type. + avg_cycle_time_days: Mean cycle time for resolved issues of this type. + bug_resolution_time_avg: Same as avg_cycle_time_days when type is Bug (None otherwise). + """ + + issue_type: str + count: int + resolved_count: int + avg_cycle_time_days: float | None + bug_resolution_time_avg: float | None # Only populated for Bug type + + +# ============================================================================= +# Individual Metric Calculation Functions (T016-T022) +# ============================================================================= + + +def calculate_cycle_time( + created: datetime, + resolution_date: datetime | None, +) -> float | None: + """Calculate cycle time in days between creation and resolution (FR-001). + + Args: + created: Issue creation timestamp. + resolution_date: Resolution timestamp (None if unresolved). + + Returns: + Cycle time in days as float (2 decimal precision), or None if unresolved. + Returns None with warning logged if cycle time is negative. + """ + if resolution_date is None: + return None + + delta = resolution_date - created + days = delta.total_seconds() / 86400 # Convert to days + + if days < 0: + logger.warning( + "Negative cycle time detected: created=%s, resolved=%s. Setting to None.", + created.isoformat(), + resolution_date.isoformat(), + ) + return None + + return round(days, 2) + + +def calculate_aging( + created: datetime, + resolution_date: datetime | None, + now: datetime | None = None, +) -> float | None: + """Calculate aging in days for open issues (FR-002). + + Args: + created: Issue creation timestamp. + resolution_date: Resolution timestamp (None if unresolved). + now: Current timestamp (defaults to UTC now). + + Returns: + Aging in days as float (2 decimal precision), or None if resolved. + Returns None with warning logged if aging is negative. + """ + if resolution_date is not None: + return None # Resolved issues don't have aging + + if now is None: + now = datetime.now(timezone.utc) + + delta = now - created + days = delta.total_seconds() / 86400 + + if days < 0: + logger.warning( + "Negative aging detected: created=%s, now=%s. Setting to None.", + created.isoformat(), + now.isoformat(), + ) + return None + + return round(days, 2) + + +def detect_acceptance_criteria(description: str) -> bool: + """Detect presence of acceptance criteria in description (FR-005). + + Uses regex patterns to identify common AC formats: + - Given/When/Then (BDD) + - AC: or Acceptance Criteria: headers + - Checkbox lists (markdown) + - Heading-based AC sections + + Args: + description: Plain text description content. + + Returns: + True if any AC pattern matches. + """ + if not description: + return False + + return any(re.search(pattern, description, re.MULTILINE) for pattern in AC_PATTERNS) + + +def calculate_description_quality( + description: str, + has_ac: bool, +) -> int: + """Calculate description quality score 0-100 (FR-004). + + Uses balanced weighting: + - 40% length (>100 chars = full score, linear interpolation below) + - 40% acceptance criteria presence (boolean) + - 20% formatting (10% headers + 10% lists) + + Args: + description: Plain text description content. + has_ac: Whether acceptance criteria were detected. + + Returns: + Quality score 0-100 as integer. + """ + score = 0 + + # Length component (40 points max) + length = len(description.strip()) if description else 0 + length_score = min( + QUALITY_WEIGHT_LENGTH, + int(length * QUALITY_WEIGHT_LENGTH / QUALITY_LENGTH_THRESHOLD), + ) + score += length_score + + # AC presence component (40 points) + if has_ac: + score += QUALITY_WEIGHT_AC + + # Formatting component (20 points max) + if description: + # Check for headers (10 pts) + has_headers = bool(re.search(r'^#+\s', description, re.MULTILINE)) + if has_headers: + score += QUALITY_WEIGHT_FORMAT // 2 + + # Check for lists (10 pts) + has_lists = bool(re.search(r'^\s*[-*]\s', description, re.MULTILINE)) + if has_lists: + score += QUALITY_WEIGHT_FORMAT // 2 + + return min(100, score) # Cap at 100 + + +def calculate_comment_metrics( + comments: list[JiraComment], + issue_created: datetime, +) -> tuple[int, float | None, bool]: + """Calculate comment-related metrics (FR-003, FR-006, FR-007). + + Args: + comments: List of JiraComment objects for the issue. + issue_created: Issue creation timestamp. + + Returns: + Tuple of (comments_count, comment_velocity_hours, silent_issue). + """ + comments_count = len(comments) + silent_issue = comments_count == 0 + + if silent_issue: + return comments_count, None, silent_issue + + # Find first comment timestamp for velocity calculation + first_comment = min(comments, key=lambda c: c.created) + delta = first_comment.created - issue_created + velocity_hours = round(delta.total_seconds() / 3600, 2) # Convert to hours + + # Handle negative velocity (data error - comment before issue creation) + if velocity_hours < 0: + logger.warning( + "Negative comment velocity detected. Setting to 0.", + ) + velocity_hours = 0.0 + + return comments_count, velocity_hours, silent_issue + + +def calculate_same_day_resolution( + created: datetime, + resolution_date: datetime | None, +) -> bool: + """Check if issue was resolved same calendar day as created (FR-008). + + Compares dates in UTC timezone. + + Args: + created: Issue creation timestamp. + resolution_date: Resolution timestamp (None if unresolved). + + Returns: + True if resolved on same calendar day as created. + """ + if resolution_date is None: + return False + + # Compare dates (ignoring time) + return created.date() == resolution_date.date() + + +def calculate_cross_team_score(comments: list[JiraComment]) -> int: + """Calculate cross-team collaboration score 0-100 (FR-009). + + Uses diminishing scale based on distinct comment authors: + - 0 authors = 0 + - 1 author = 25 + - 2 authors = 50 + - 3 authors = 75 + - 4 authors = 90 + - 5+ authors = 100 + + Args: + comments: List of JiraComment objects for the issue. + + Returns: + Cross-team score 0-100. + """ + if not comments: + return 0 + + unique_authors = len({c.author for c in comments}) + return CROSS_TEAM_SCALE.get(unique_authors, 100) + + +def detect_reopens(changelog: list[dict]) -> int: + """Detect number of reopens from issue changelog (FR-022). + + A reopen is when status transitions FROM a "Done" category + TO a non-Done category. + + Args: + changelog: List of changelog entries from Jira API. + + Returns: + Number of reopen events detected. + """ + reopen_count = 0 + + for entry in changelog: + items = entry.get("items", []) + for item in items: + if item.get("field") != "status": + continue + + from_status = item.get("fromString", "") + to_status = item.get("toString", "") + + # Check if transition is from Done category to non-Done + if from_status in DONE_STATUSES and to_status not in DONE_STATUSES: + reopen_count += 1 + + return reopen_count + + +# ============================================================================= +# Composite Metric Calculator (T023) +# ============================================================================= + + +class MetricsCalculator: + """Calculator for Jira quality metrics. + + Provides methods to calculate issue-level metrics and + aggregate them into project, person, and type summaries. + """ + + def calculate_issue_metrics( + self, + issue: JiraIssue, + comments: list[JiraComment], + changelog: list[dict] | None = None, + now: datetime | None = None, + ) -> IssueMetrics: + """Calculate all metrics for a single issue. + + Args: + issue: JiraIssue object. + comments: List of comments for the issue. + changelog: Optional changelog for reopen detection. + now: Current timestamp (defaults to UTC now). + + Returns: + IssueMetrics with all calculated values. + """ + # Calculate individual metrics + cycle_time = calculate_cycle_time(issue.created, issue.resolution_date) + aging = calculate_aging(issue.created, issue.resolution_date, now) + + has_ac = detect_acceptance_criteria(issue.description) + quality_score = calculate_description_quality(issue.description, has_ac) + + comments_count, velocity, silent = calculate_comment_metrics( + comments, issue.created + ) + + same_day = calculate_same_day_resolution(issue.created, issue.resolution_date) + cross_team = calculate_cross_team_score(comments) + + # Reopen detection (best-effort) + reopen_count = 0 + if changelog: + reopen_count = detect_reopens(changelog) + + return IssueMetrics( + issue=issue, + cycle_time_days=cycle_time, + aging_days=aging, + comments_count=comments_count, + description_quality_score=quality_score, + acceptance_criteria_present=has_ac, + comment_velocity_hours=velocity, + silent_issue=silent, + same_day_resolution=same_day, + cross_team_score=cross_team, + reopen_count=reopen_count, + ) + + def aggregate_project_metrics( + self, + issue_metrics: list[IssueMetrics], + project_key: str, + ) -> ProjectMetrics: + """Aggregate issue metrics into project-level summary (FR-010 to FR-014). + + Args: + issue_metrics: List of IssueMetrics for the project. + project_key: Project key for the summary. + + Returns: + ProjectMetrics with aggregated values. + """ + total = len(issue_metrics) + + if total == 0: + return ProjectMetrics( + project_key=project_key, + total_issues=0, + resolved_count=0, + unresolved_count=0, + avg_cycle_time_days=None, + median_cycle_time_days=None, + bug_count=0, + bug_ratio_percent=0.0, + same_day_resolution_rate_percent=0.0, + avg_description_quality=0.0, + silent_issues_ratio_percent=0.0, + avg_comments_per_issue=0.0, + avg_comment_velocity_hours=None, + reopen_rate_percent=0.0, + ) + + # Separate resolved and unresolved + resolved = [m for m in issue_metrics if m.cycle_time_days is not None] + resolved_count = len(resolved) + unresolved_count = total - resolved_count + + # Cycle time calculations + cycle_times = [m.cycle_time_days for m in resolved if m.cycle_time_days is not None] + avg_cycle = round(mean(cycle_times), 2) if cycle_times else None + median_cycle = round(median(cycle_times), 2) if cycle_times else None + + # Bug metrics + bug_count = sum(1 for m in issue_metrics if m.issue.issue_type == "Bug") + bug_ratio = round((bug_count / total) * 100, 2) if total > 0 else 0.0 + + # Same-day resolution rate + same_day_count = sum(1 for m in issue_metrics if m.same_day_resolution) + same_day_rate = round((same_day_count / resolved_count) * 100, 2) if resolved_count > 0 else 0.0 + + # Quality metrics + avg_quality = round(mean(m.description_quality_score for m in issue_metrics), 2) + + # Silent issues + silent_count = sum(1 for m in issue_metrics if m.silent_issue) + silent_ratio = round((silent_count / total) * 100, 2) + + # Comment metrics + avg_comments = round(mean(m.comments_count for m in issue_metrics), 2) + + # Comment velocity (excluding silent issues) + velocities = [m.comment_velocity_hours for m in issue_metrics if m.comment_velocity_hours is not None] + avg_velocity = round(mean(velocities), 2) if velocities else None + + # Reopen rate + reopened_count = sum(1 for m in issue_metrics if m.reopen_count > 0) + reopen_rate = round((reopened_count / resolved_count) * 100, 2) if resolved_count > 0 else 0.0 + + return ProjectMetrics( + project_key=project_key, + total_issues=total, + resolved_count=resolved_count, + unresolved_count=unresolved_count, + avg_cycle_time_days=avg_cycle, + median_cycle_time_days=median_cycle, + bug_count=bug_count, + bug_ratio_percent=bug_ratio, + same_day_resolution_rate_percent=same_day_rate, + avg_description_quality=avg_quality, + silent_issues_ratio_percent=silent_ratio, + avg_comments_per_issue=avg_comments, + avg_comment_velocity_hours=avg_velocity, + reopen_rate_percent=reopen_rate, + ) + + def aggregate_person_metrics( + self, + issue_metrics: list[IssueMetrics], + ) -> list[PersonMetrics]: + """Aggregate issue metrics into per-person summaries (FR-015 to FR-018). + + Issues without assignee are excluded. + + Args: + issue_metrics: List of IssueMetrics. + + Returns: + List of PersonMetrics, one per unique assignee. + """ + # Group by assignee (excluding unassigned) + by_assignee: dict[str, list[IssueMetrics]] = {} + for m in issue_metrics: + assignee = m.issue.assignee + if assignee: # Skip unassigned issues + if assignee not in by_assignee: + by_assignee[assignee] = [] + by_assignee[assignee].append(m) + + result = [] + for assignee_name, metrics in by_assignee.items(): + # Count WIP (open issues) + wip = sum(1 for m in metrics if m.cycle_time_days is None) + + # Count resolved + resolved_list = [m for m in metrics if m.cycle_time_days is not None] + resolved_count = len(resolved_list) + + # Average cycle time for resolved issues + cycle_times = [m.cycle_time_days for m in resolved_list if m.cycle_time_days is not None] + avg_cycle = round(mean(cycle_times), 2) if cycle_times else None + + # Bug count + bug_count = sum(1 for m in metrics if m.issue.issue_type == "Bug") + + result.append(PersonMetrics( + assignee_name=assignee_name, + wip_count=wip, + resolved_count=resolved_count, + total_assigned=len(metrics), + avg_cycle_time_days=avg_cycle, + bug_count_assigned=bug_count, + )) + + return result + + def aggregate_type_metrics( + self, + issue_metrics: list[IssueMetrics], + ) -> list[TypeMetrics]: + """Aggregate issue metrics into per-type summaries (FR-019 to FR-021). + + Args: + issue_metrics: List of IssueMetrics. + + Returns: + List of TypeMetrics, one per unique issue type. + """ + # Group by issue type + by_type: dict[str, list[IssueMetrics]] = {} + for m in issue_metrics: + issue_type = m.issue.issue_type + if issue_type not in by_type: + by_type[issue_type] = [] + by_type[issue_type].append(m) + + result = [] + for issue_type, metrics in by_type.items(): + count = len(metrics) + + # Resolved issues + resolved_list = [m for m in metrics if m.cycle_time_days is not None] + resolved_count = len(resolved_list) + + # Average cycle time + cycle_times = [m.cycle_time_days for m in resolved_list if m.cycle_time_days is not None] + avg_cycle = round(mean(cycle_times), 2) if cycle_times else None + + # Bug resolution time (only for Bug type) + bug_resolution_avg = avg_cycle if issue_type == "Bug" else None + + result.append(TypeMetrics( + issue_type=issue_type, + count=count, + resolved_count=resolved_count, + avg_cycle_time_days=avg_cycle, + bug_resolution_time_avg=bug_resolution_avg, + )) + + return result diff --git a/src/github_analyzer/api/jira_client.py b/src/github_analyzer/api/jira_client.py index ccd2994..aa6c7b2 100644 --- a/src/github_analyzer/api/jira_client.py +++ b/src/github_analyzer/api/jira_client.py @@ -548,6 +548,38 @@ def get_comments(self, issue_key: str) -> list[JiraComment]: return comments + def get_issue_changelog(self, issue_key: str) -> list[dict[str, Any]]: + """Get changelog entries for an issue. + + Used for detecting reopens (status transitions from Done to non-Done). + Implements best-effort retrieval with graceful degradation: + - Returns empty list on 403 (permission denied) + - Returns empty list on 404 (issue not found) + - Other errors are propagated + + Args: + issue_key: The issue key (e.g., PROJ-123). + + Returns: + List of changelog entries with status transitions. + Empty list if access denied or issue not found. + + Raises: + JiraAPIError: For server errors (5xx) or other failures. + """ + try: + response = self._make_request( + "GET", + f"/rest/api/{self.api_version}/issue/{issue_key}/changelog", + ) + values: list[dict[str, Any]] = response.get("values", []) + return values + + except (JiraPermissionError, JiraNotFoundError): + # Graceful degradation per spec assumptions: + # "If API returns 403 (permissions) or 404, reopen_count defaults to 0" + return [] + def _parse_datetime(self, value: str | None) -> datetime | None: """Parse Jira datetime string to datetime object. diff --git a/src/github_analyzer/cli/main.py b/src/github_analyzer/cli/main.py index 0c22f7f..d183252 100644 --- a/src/github_analyzer/cli/main.py +++ b/src/github_analyzer/cli/main.py @@ -693,10 +693,12 @@ def main() -> int: finally: analyzer.close() - # Run Jira extraction + # Run Jira extraction with quality metrics (Feature 003) if DataSource.JIRA in sources and jira_config and project_keys: output.log("Starting Jira extraction...", "info") - from src.github_analyzer.api.jira_client import JiraClient + from src.github_analyzer.analyzers.jira_metrics import IssueMetrics, MetricsCalculator + from src.github_analyzer.api.jira_client import JiraClient, JiraComment + from src.github_analyzer.exporters.jira_metrics_exporter import JiraMetricsExporter client = JiraClient(jira_config) since = datetime.now(timezone.utc) - timedelta(days=config.days) @@ -708,18 +710,59 @@ def main() -> int: output.log("Fetching comments...", "info") all_comments = [] + issue_comments_map: dict[str, list[JiraComment]] = {} # Map issue key to comments for issue in all_issues: comments = client.get_comments(issue.key) all_comments.extend(comments) + issue_comments_map[issue.key] = comments output.log(f"Found {len(all_comments)} comments", "success") - # Export Jira data to CSV + # Calculate quality metrics for each issue (Feature 003) + output.log("Calculating quality metrics...", "info") + calculator = MetricsCalculator() + issue_metrics = [] + for issue in all_issues: + comments = issue_comments_map.get(issue.key, []) + # Best-effort changelog retrieval (gracefully handles 403/404) + changelog = client.get_issue_changelog(issue.key) + metrics = calculator.calculate_issue_metrics(issue, comments, changelog) + issue_metrics.append(metrics) + output.log(f"Calculated metrics for {len(issue_metrics)} issues", "success") + + # Export Jira data to CSV with metrics jira_exporter = JiraExporter(config.output_dir) - issues_file = jira_exporter.export_issues(all_issues) + metrics_exporter = JiraMetricsExporter(config.output_dir) + + # Export issues with embedded metrics (extended CSV) + issues_file = jira_exporter.export_issues_with_metrics(issue_metrics) comments_file = jira_exporter.export_comments(all_comments) output.log(f"Exported Jira issues to {issues_file}", "success") output.log(f"Exported Jira comments to {comments_file}", "success") + # Export aggregated metrics (project, person, type summaries) + # Group issues by project for project-level aggregation + issues_by_project: dict[str, list[IssueMetrics]] = {} + for m in issue_metrics: + proj_key = m.issue.project_key + if proj_key not in issues_by_project: + issues_by_project[proj_key] = [] + issues_by_project[proj_key].append(m) + + project_metrics = [ + calculator.aggregate_project_metrics(metrics, proj_key) + for proj_key, metrics in issues_by_project.items() + ] + person_metrics = calculator.aggregate_person_metrics(issue_metrics) + type_metrics = calculator.aggregate_type_metrics(issue_metrics) + + project_file = metrics_exporter.export_project_metrics(project_metrics) + person_file = metrics_exporter.export_person_metrics(person_metrics) + type_file = metrics_exporter.export_type_metrics(type_metrics) + + output.log(f"Exported project metrics to {project_file}", "success") + output.log(f"Exported person metrics to {person_file}", "success") + output.log(f"Exported type metrics to {type_file}", "success") + return 0 except ConfigurationError as e: diff --git a/src/github_analyzer/config/settings.py b/src/github_analyzer/config/settings.py index 0165907..55122fe 100644 --- a/src/github_analyzer/config/settings.py +++ b/src/github_analyzer/config/settings.py @@ -20,6 +20,42 @@ from src.github_analyzer.core.exceptions import ConfigurationError, ValidationError, mask_token +# ============================================================================= +# Jira Quality Metrics Configuration Constants (Feature 003) +# ============================================================================= + +# Description Quality Score weights (FR-004) +# Total must equal 100 +QUALITY_WEIGHT_LENGTH = 40 # Max points for description length +QUALITY_WEIGHT_AC = 40 # Points for acceptance criteria presence +QUALITY_WEIGHT_FORMAT = 20 # Max points for formatting (headers + lists) +QUALITY_LENGTH_THRESHOLD = 100 # Chars needed for full length score + +# Cross-team Score scale (FR-009) +# Maps number of distinct comment authors to collaboration score +CROSS_TEAM_SCALE: dict[int, int] = { + 0: 0, + 1: 25, + 2: 50, + 3: 75, + 4: 90, + # 5+ authors = 100 (handled in code) +} + +# Acceptance Criteria detection patterns (FR-005) +# Regex patterns to identify AC in descriptions +AC_PATTERNS: list[str] = [ + r'(?is)\bgiven\b.*\bwhen\b.*\bthen\b', # BDD/Gherkin style (DOTALL for multiline) + r'(?i)^#+\s*acceptance\s+criteria', # Markdown heading + r'(?i)^ac\s*:', # AC: prefix + r'(?i)acceptance\s+criteria\s*:', # Full label + r'^\s*[-*]\s*\[\s*[x ]?\s*\]', # Checkbox list (markdown) +] + +# Done status categories for reopen detection (FR-022) +# Issues transitioning FROM these statuses TO non-done = reopen +DONE_STATUSES: set[str] = {'Done', 'Closed', 'Resolved', 'Complete', 'Completed'} + class DataSource(Enum): """Supported data sources for the analyzer. diff --git a/src/github_analyzer/exporters/jira_exporter.py b/src/github_analyzer/exporters/jira_exporter.py index 4f48506..840f5e7 100644 --- a/src/github_analyzer/exporters/jira_exporter.py +++ b/src/github_analyzer/exporters/jira_exporter.py @@ -2,6 +2,8 @@ This module provides the JiraExporter class for exporting Jira issues and comments to CSV files following RFC 4180 standards. + +Extended in Feature 003 to support quality metrics columns. """ from __future__ import annotations @@ -11,6 +13,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from src.github_analyzer.analyzers.jira_metrics import IssueMetrics from src.github_analyzer.api.jira_client import JiraComment, JiraIssue @@ -30,6 +33,20 @@ "project_key", ) +# Extended columns with metrics (Feature 003, contracts/csv-schemas.md) +EXTENDED_ISSUE_COLUMNS = ISSUE_COLUMNS + ( + "cycle_time_days", + "aging_days", + "comments_count", + "description_quality_score", + "acceptance_criteria_present", + "comment_velocity_hours", + "silent_issue", + "same_day_resolution", + "cross_team_score", + "reopen_count", +) + COMMENT_COLUMNS = ( "id", "issue_key", @@ -115,3 +132,69 @@ def export_comments(self, comments: list[JiraComment]) -> Path: }) return filepath + + def export_issues_with_metrics(self, metrics_list: list[IssueMetrics]) -> Path: + """Export issues with quality metrics to jira_issues_export.csv. + + Exports all original issue columns plus 10 new metric columns + per contracts/csv-schemas.md. + + Format rules: + - Floats: 2 decimal places + - Booleans: lowercase "true"/"false" + - None values: empty string + + Args: + metrics_list: List of IssueMetrics objects. + + Returns: + Path to created file. + """ + filepath = self._output_dir / "jira_issues_export.csv" + + with open(filepath, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=EXTENDED_ISSUE_COLUMNS) + writer.writeheader() + + for metrics in metrics_list: + issue = metrics.issue + writer.writerow({ + # Original columns + "key": issue.key, + "summary": issue.summary, + "description": issue.description, + "status": issue.status, + "issue_type": issue.issue_type, + "priority": issue.priority or "", + "assignee": issue.assignee or "", + "reporter": issue.reporter, + "created": issue.created.isoformat() if issue.created else "", + "updated": issue.updated.isoformat() if issue.updated else "", + "resolution_date": issue.resolution_date.isoformat() if issue.resolution_date else "", + "project_key": issue.project_key, + # Metric columns + "cycle_time_days": self._format_float(metrics.cycle_time_days), + "aging_days": self._format_float(metrics.aging_days), + "comments_count": str(metrics.comments_count), + "description_quality_score": str(metrics.description_quality_score), + "acceptance_criteria_present": self._format_bool(metrics.acceptance_criteria_present), + "comment_velocity_hours": self._format_float(metrics.comment_velocity_hours), + "silent_issue": self._format_bool(metrics.silent_issue), + "same_day_resolution": self._format_bool(metrics.same_day_resolution), + "cross_team_score": str(metrics.cross_team_score), + "reopen_count": str(metrics.reopen_count), + }) + + return filepath + + @staticmethod + def _format_float(value: float | None) -> str: + """Format float with 2 decimal places, or empty string if None.""" + if value is None: + return "" + return f"{value:.2f}" + + @staticmethod + def _format_bool(value: bool) -> str: + """Format boolean as lowercase 'true' or 'false'.""" + return "true" if value else "false" diff --git a/src/github_analyzer/exporters/jira_metrics_exporter.py b/src/github_analyzer/exporters/jira_metrics_exporter.py new file mode 100644 index 0000000..fa1b7c0 --- /dev/null +++ b/src/github_analyzer/exporters/jira_metrics_exporter.py @@ -0,0 +1,170 @@ +"""Jira metrics CSV export functionality. + +This module provides the JiraMetricsExporter class for exporting +aggregated Jira metrics to CSV files following RFC 4180 standards. + +Implements: T029, T030, T034, T038 per tasks.md +""" + +from __future__ import annotations + +import csv +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from src.github_analyzer.analyzers.jira_metrics import ( + PersonMetrics, + ProjectMetrics, + TypeMetrics, + ) + + +# Column definitions for metrics CSV exports per contracts/csv-schemas.md +PROJECT_COLUMNS = ( + "project_key", + "total_issues", + "resolved_count", + "unresolved_count", + "avg_cycle_time_days", + "median_cycle_time_days", + "bug_count", + "bug_ratio_percent", + "same_day_resolution_rate_percent", + "avg_description_quality", + "silent_issues_ratio_percent", + "avg_comments_per_issue", + "avg_comment_velocity_hours", + "reopen_rate_percent", +) + +PERSON_COLUMNS = ( + "assignee_name", + "wip_count", + "resolved_count", + "total_assigned", + "avg_cycle_time_days", + "bug_count_assigned", +) + +TYPE_COLUMNS = ( + "issue_type", + "count", + "resolved_count", + "avg_cycle_time_days", + "bug_resolution_time_avg", +) + + +class JiraMetricsExporter: + """Export aggregated Jira metrics to CSV files. + + Creates CSV files in the specified output directory with + consistent naming and RFC 4180 compliant formatting. + """ + + def __init__(self, output_dir: str | Path) -> None: + """Initialize exporter with output directory. + + Creates directory if it doesn't exist. + + Args: + output_dir: Directory for output files. + """ + self._output_dir = Path(output_dir) + self._output_dir.mkdir(parents=True, exist_ok=True) + + def export_project_metrics(self, metrics_list: list[ProjectMetrics]) -> Path: + """Export project metrics to jira_project_metrics.csv. + + Args: + metrics_list: List of ProjectMetrics objects. + + Returns: + Path to created file. + """ + filepath = self._output_dir / "jira_project_metrics.csv" + + with open(filepath, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=PROJECT_COLUMNS) + writer.writeheader() + + for metrics in metrics_list: + writer.writerow({ + "project_key": metrics.project_key, + "total_issues": str(metrics.total_issues), + "resolved_count": str(metrics.resolved_count), + "unresolved_count": str(metrics.unresolved_count), + "avg_cycle_time_days": self._format_float(metrics.avg_cycle_time_days), + "median_cycle_time_days": self._format_float(metrics.median_cycle_time_days), + "bug_count": str(metrics.bug_count), + "bug_ratio_percent": self._format_float(metrics.bug_ratio_percent), + "same_day_resolution_rate_percent": self._format_float(metrics.same_day_resolution_rate_percent), + "avg_description_quality": self._format_float(metrics.avg_description_quality), + "silent_issues_ratio_percent": self._format_float(metrics.silent_issues_ratio_percent), + "avg_comments_per_issue": self._format_float(metrics.avg_comments_per_issue), + "avg_comment_velocity_hours": self._format_float(metrics.avg_comment_velocity_hours), + "reopen_rate_percent": self._format_float(metrics.reopen_rate_percent), + }) + + return filepath + + def export_person_metrics(self, metrics_list: list[PersonMetrics]) -> Path: + """Export person metrics to jira_person_metrics.csv. + + Args: + metrics_list: List of PersonMetrics objects. + + Returns: + Path to created file. + """ + filepath = self._output_dir / "jira_person_metrics.csv" + + with open(filepath, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=PERSON_COLUMNS) + writer.writeheader() + + for metrics in metrics_list: + writer.writerow({ + "assignee_name": metrics.assignee_name, + "wip_count": str(metrics.wip_count), + "resolved_count": str(metrics.resolved_count), + "total_assigned": str(metrics.total_assigned), + "avg_cycle_time_days": self._format_float(metrics.avg_cycle_time_days), + "bug_count_assigned": str(metrics.bug_count_assigned), + }) + + return filepath + + def export_type_metrics(self, metrics_list: list[TypeMetrics]) -> Path: + """Export type metrics to jira_type_metrics.csv. + + Args: + metrics_list: List of TypeMetrics objects. + + Returns: + Path to created file. + """ + filepath = self._output_dir / "jira_type_metrics.csv" + + with open(filepath, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=TYPE_COLUMNS) + writer.writeheader() + + for metrics in metrics_list: + writer.writerow({ + "issue_type": metrics.issue_type, + "count": str(metrics.count), + "resolved_count": str(metrics.resolved_count), + "avg_cycle_time_days": self._format_float(metrics.avg_cycle_time_days), + "bug_resolution_time_avg": self._format_float(metrics.bug_resolution_time_avg), + }) + + return filepath + + @staticmethod + def _format_float(value: float | None) -> str: + """Format float with 2 decimal places, or empty string if None.""" + if value is None: + return "" + return f"{value:.2f}" diff --git a/tests/fixtures/jira_responses.py b/tests/fixtures/jira_responses.py index 080f695..72141b8 100644 --- a/tests/fixtures/jira_responses.py +++ b/tests/fixtures/jira_responses.py @@ -316,3 +316,392 @@ "X-RateLimit-Remaining": "0", "Retry-After": "60", } + + +# ============================================================================= +# Quality Metrics Test Fixtures (Feature 003) +# ============================================================================= + +# Issue with high quality description (has AC, headers, lists, long text) +ISSUE_HIGH_QUALITY = { + "id": "10050", + "key": "PROJ-50", + "self": "https://company.atlassian.net/rest/api/3/issue/10050", + "fields": { + "summary": "Implement user authentication", + "description": { + "type": "doc", + "version": 1, + "content": [ + { + "type": "heading", + "attrs": {"level": 2}, + "content": [{"type": "text", "text": "Description"}], + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "As a user, I want to be able to log in to the application securely so that my data is protected. " + "This feature should support OAuth2 and traditional username/password authentication methods. " + "The implementation must follow security best practices including rate limiting and account lockout.", + } + ], + }, + { + "type": "heading", + "attrs": {"level": 2}, + "content": [{"type": "text", "text": "Acceptance Criteria"}], + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Given a valid username and password, When the user submits login, Then they are authenticated"} + ], + } + ], + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Given invalid credentials, When login attempted 5 times, Then account is locked"} + ], + } + ], + }, + ], + }, + ], + }, + "status": {"name": "Done", "id": "3"}, + "issuetype": {"name": "Story", "id": "2"}, + "priority": {"name": "High", "id": "2"}, + "assignee": {"displayName": "John Doe", "accountId": "123"}, + "reporter": {"displayName": "Jane Smith", "accountId": "456"}, + "created": "2025-11-01T09:00:00.000+0000", + "updated": "2025-11-15T16:00:00.000+0000", + "resolutiondate": "2025-11-15T16:00:00.000+0000", + "project": {"key": "PROJ"}, + }, +} + +# Issue with poor quality description (short, no AC, no formatting) +ISSUE_LOW_QUALITY = { + "id": "10051", + "key": "PROJ-51", + "self": "https://company.atlassian.net/rest/api/3/issue/10051", + "fields": { + "summary": "Fix bug", + "description": { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Something is broken."}], + } + ], + }, + "status": {"name": "Open", "id": "1"}, + "issuetype": {"name": "Bug", "id": "1"}, + "priority": {"name": "Medium", "id": "3"}, + "assignee": None, + "reporter": {"displayName": "Bob Wilson", "accountId": "789"}, + "created": "2025-11-20T10:00:00.000+0000", + "updated": "2025-11-20T10:00:00.000+0000", + "resolutiondate": None, + "project": {"key": "PROJ"}, + }, +} + +# Issue with no description +ISSUE_NO_DESCRIPTION = { + "id": "10052", + "key": "PROJ-52", + "self": "https://company.atlassian.net/rest/api/3/issue/10052", + "fields": { + "summary": "Quick task", + "description": None, + "status": {"name": "Done", "id": "3"}, + "issuetype": {"name": "Task", "id": "3"}, + "priority": {"name": "Low", "id": "4"}, + "assignee": {"displayName": "Alice Johnson", "accountId": "321"}, + "reporter": {"displayName": "John Doe", "accountId": "123"}, + "created": "2025-11-10T08:00:00.000+0000", + "updated": "2025-11-10T17:00:00.000+0000", + "resolutiondate": "2025-11-10T17:00:00.000+0000", + "project": {"key": "PROJ"}, + }, +} + +# Issue resolved same day (created and resolved same calendar day) +ISSUE_SAME_DAY_RESOLVED = { + "id": "10053", + "key": "PROJ-53", + "self": "https://company.atlassian.net/rest/api/3/issue/10053", + "fields": { + "summary": "Hot fix", + "description": { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Critical production issue that needs immediate attention."}], + } + ], + }, + "status": {"name": "Done", "id": "3"}, + "issuetype": {"name": "Bug", "id": "1"}, + "priority": {"name": "Critical", "id": "1"}, + "assignee": {"displayName": "John Doe", "accountId": "123"}, + "reporter": {"displayName": "Support Team", "accountId": "support"}, + "created": "2025-11-25T09:00:00.000+0000", + "updated": "2025-11-25T14:00:00.000+0000", + "resolutiondate": "2025-11-25T14:00:00.000+0000", + "project": {"key": "PROJ"}, + }, +} + +# Issue with long cycle time (14 days) +ISSUE_LONG_CYCLE_TIME = { + "id": "10054", + "key": "PROJ-54", + "self": "https://company.atlassian.net/rest/api/3/issue/10054", + "fields": { + "summary": "Complex refactoring", + "description": { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Major refactoring of the authentication module."}], + } + ], + }, + "status": {"name": "Done", "id": "3"}, + "issuetype": {"name": "Story", "id": "2"}, + "priority": {"name": "Medium", "id": "3"}, + "assignee": {"displayName": "Jane Smith", "accountId": "456"}, + "reporter": {"displayName": "Product Manager", "accountId": "pm"}, + "created": "2025-11-01T10:00:00.000+0000", + "updated": "2025-11-15T10:00:00.000+0000", + "resolutiondate": "2025-11-15T10:00:00.000+0000", + "project": {"key": "PROJ"}, + }, +} + +# Issue still open (for aging calculation) +ISSUE_OPEN_AGING = { + "id": "10055", + "key": "PROJ-55", + "self": "https://company.atlassian.net/rest/api/3/issue/10055", + "fields": { + "summary": "Ongoing investigation", + "description": { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Investigating intermittent performance issues."}], + } + ], + }, + "status": {"name": "In Progress", "id": "2"}, + "issuetype": {"name": "Task", "id": "3"}, + "priority": {"name": "High", "id": "2"}, + "assignee": {"displayName": "Bob Wilson", "accountId": "789"}, + "reporter": {"displayName": "Operations", "accountId": "ops"}, + "created": "2025-11-01T09:00:00.000+0000", # Old issue for aging test + "updated": "2025-11-28T09:00:00.000+0000", + "resolutiondate": None, + "project": {"key": "PROJ"}, + }, +} + +# Comments for testing cross-team score (multiple authors) +COMMENTS_MULTIPLE_AUTHORS = { + "startAt": 0, + "maxResults": 50, + "total": 5, + "comments": [ + { + "id": "20001", + "author": {"displayName": "John Doe", "accountId": "123"}, + "body": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Started working on this."}]}]}, + "created": "2025-11-02T10:00:00.000+0000", + "updated": "2025-11-02T10:00:00.000+0000", + }, + { + "id": "20002", + "author": {"displayName": "Jane Smith", "accountId": "456"}, + "body": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Code review feedback."}]}]}, + "created": "2025-11-03T14:00:00.000+0000", + "updated": "2025-11-03T14:00:00.000+0000", + }, + { + "id": "20003", + "author": {"displayName": "Bob Wilson", "accountId": "789"}, + "body": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "QA tested, all good."}]}]}, + "created": "2025-11-04T09:00:00.000+0000", + "updated": "2025-11-04T09:00:00.000+0000", + }, + { + "id": "20004", + "author": {"displayName": "Alice Johnson", "accountId": "321"}, + "body": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Security review complete."}]}]}, + "created": "2025-11-05T11:00:00.000+0000", + "updated": "2025-11-05T11:00:00.000+0000", + }, + { + "id": "20005", + "author": {"displayName": "John Doe", "accountId": "123"}, # Same author as first + "body": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Merged to main."}]}]}, + "created": "2025-11-06T16:00:00.000+0000", + "updated": "2025-11-06T16:00:00.000+0000", + }, + ], +} + +# Comments with single author (low cross-team score) +COMMENTS_SINGLE_AUTHOR = { + "startAt": 0, + "maxResults": 50, + "total": 2, + "comments": [ + { + "id": "20010", + "author": {"displayName": "John Doe", "accountId": "123"}, + "body": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Working on it."}]}]}, + "created": "2025-11-02T10:00:00.000+0000", + "updated": "2025-11-02T10:00:00.000+0000", + }, + { + "id": "20011", + "author": {"displayName": "John Doe", "accountId": "123"}, + "body": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Done."}]}]}, + "created": "2025-11-03T14:00:00.000+0000", + "updated": "2025-11-03T14:00:00.000+0000", + }, + ], +} + +# Changelog response for reopen detection (FR-022) +CHANGELOG_WITH_REOPEN = { + "startAt": 0, + "maxResults": 100, + "total": 4, + "values": [ + { + "id": "30001", + "author": {"displayName": "John Doe", "accountId": "123"}, + "created": "2025-11-01T10:00:00.000+0000", + "items": [ + {"field": "status", "fieldtype": "jira", "from": "1", "fromString": "Open", "to": "2", "toString": "In Progress"} + ], + }, + { + "id": "30002", + "author": {"displayName": "John Doe", "accountId": "123"}, + "created": "2025-11-05T16:00:00.000+0000", + "items": [ + {"field": "status", "fieldtype": "jira", "from": "2", "fromString": "In Progress", "to": "3", "toString": "Done"} + ], + }, + { + "id": "30003", + "author": {"displayName": "QA Team", "accountId": "qa"}, + "created": "2025-11-06T09:00:00.000+0000", + "items": [ + {"field": "status", "fieldtype": "jira", "from": "3", "fromString": "Done", "to": "2", "toString": "In Progress"} # REOPEN + ], + }, + { + "id": "30004", + "author": {"displayName": "John Doe", "accountId": "123"}, + "created": "2025-11-07T14:00:00.000+0000", + "items": [ + {"field": "status", "fieldtype": "jira", "from": "2", "fromString": "In Progress", "to": "3", "toString": "Done"} + ], + }, + ], +} + +# Changelog without reopens +CHANGELOG_NO_REOPEN = { + "startAt": 0, + "maxResults": 100, + "total": 2, + "values": [ + { + "id": "30010", + "author": {"displayName": "Alice Johnson", "accountId": "321"}, + "created": "2025-11-10T10:00:00.000+0000", + "items": [ + {"field": "status", "fieldtype": "jira", "from": "1", "fromString": "Open", "to": "2", "toString": "In Progress"} + ], + }, + { + "id": "30011", + "author": {"displayName": "Alice Johnson", "accountId": "321"}, + "created": "2025-11-10T17:00:00.000+0000", + "items": [ + {"field": "status", "fieldtype": "jira", "from": "2", "fromString": "In Progress", "to": "3", "toString": "Done"} + ], + }, + ], +} + +# Empty changelog +CHANGELOG_EMPTY = { + "startAt": 0, + "maxResults": 100, + "total": 0, + "values": [], +} + +# Description with checkbox-style AC +DESCRIPTION_WITH_CHECKBOX_AC = """ +## User Story +As a developer, I want to implement the login feature. + +## Acceptance Criteria +- [ ] User can enter username +- [x] User can enter password +- [ ] Login button is enabled when both fields have values +""" + +# Description with Given/When/Then AC +DESCRIPTION_WITH_GWT_AC = """ +Feature: User Authentication + +Given a registered user +When they enter valid credentials +Then they should be logged in successfully + +Given an unregistered user +When they try to login +Then they should see an error message +""" + +# Set of sample issues for aggregation testing +ISSUES_FOR_AGGREGATION = [ + ISSUE_HIGH_QUALITY, + ISSUE_LOW_QUALITY, + ISSUE_NO_DESCRIPTION, + ISSUE_SAME_DAY_RESOLVED, + ISSUE_LONG_CYCLE_TIME, + ISSUE_OPEN_AGING, +] diff --git a/tests/integration/test_jira_metrics_flow.py b/tests/integration/test_jira_metrics_flow.py new file mode 100644 index 0000000..f0c3fae --- /dev/null +++ b/tests/integration/test_jira_metrics_flow.py @@ -0,0 +1,265 @@ +"""Integration test for full Jira quality metrics flow. + +Tests the complete pipeline from issue data to all 4 CSV exports: +- jira_issues_export.csv (with metrics columns) +- jira_project_metrics.csv +- jira_person_metrics.csv +- jira_type_metrics.csv + +This test validates that all components work together correctly. +""" + +from __future__ import annotations + +import csv +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from src.github_analyzer.analyzers.jira_metrics import MetricsCalculator +from src.github_analyzer.api.jira_client import JiraComment, JiraIssue +from src.github_analyzer.exporters.jira_exporter import JiraExporter +from src.github_analyzer.exporters.jira_metrics_exporter import JiraMetricsExporter + + +def make_test_issue( + key: str, + project_key: str = "PROJ", + issue_type: str = "Story", + assignee: str | None = "John Doe", + status: str = "Done", + created_offset_days: int = 14, + resolution_offset_days: int | None = 7, + description: str = "## Description\n\nThis is a test issue with proper formatting.\n\n## Acceptance Criteria\n\n- [ ] First criterion\n- [x] Second criterion", +) -> JiraIssue: + """Create a test JiraIssue with sensible defaults.""" + now = datetime.now(timezone.utc) + created = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) + resolution = None + if resolution_offset_days is not None: + resolution = datetime(2025, 11, 1 + (created_offset_days - resolution_offset_days), 10, 0, 0, tzinfo=timezone.utc) + + return JiraIssue( + key=key, + summary=f"Test issue {key}", + description=description, + status=status, + issue_type=issue_type, + priority="Medium", + assignee=assignee, + reporter="Jane Smith", + created=created, + updated=now, + resolution_date=resolution, + project_key=project_key, + ) + + +def make_test_comment( + comment_id: str, + issue_key: str, + author: str = "John Doe", + offset_hours: int = 24, +) -> JiraComment: + """Create a test JiraComment.""" + created = datetime(2025, 11, 2, 10, 0, 0, tzinfo=timezone.utc) + return JiraComment( + id=comment_id, + issue_key=issue_key, + author=author, + created=created, + body="Test comment", + ) + + +class TestFullMetricsFlow: + """Integration tests for full metrics calculation and export flow.""" + + def test_full_export_produces_all_files(self, tmp_path: Path) -> None: + """Given issues with comments, export all 4 CSV files.""" + # Arrange: Create test data + issues = [ + make_test_issue("PROJ-1", issue_type="Bug", assignee="John"), + make_test_issue("PROJ-2", issue_type="Story", assignee="Jane"), + make_test_issue("PROJ-3", issue_type="Bug", assignee="John"), + make_test_issue("PROJ-4", issue_type="Task", assignee=None, status="Open", resolution_offset_days=None), + ] + + comments_map = { + "PROJ-1": [ + make_test_comment("1", "PROJ-1", author="Alice"), + make_test_comment("2", "PROJ-1", author="Bob"), + ], + "PROJ-2": [make_test_comment("3", "PROJ-2", author="John")], + "PROJ-3": [], # Silent issue + "PROJ-4": [make_test_comment("4", "PROJ-4", author="Jane")], + } + + # Act: Calculate metrics + calculator = MetricsCalculator() + issue_metrics = [] + for issue in issues: + comments = comments_map.get(issue.key, []) + metrics = calculator.calculate_issue_metrics(issue, comments) + issue_metrics.append(metrics) + + # Export issues with metrics + jira_exporter = JiraExporter(tmp_path) + issues_file = jira_exporter.export_issues_with_metrics(issue_metrics) + + # Export aggregated metrics + metrics_exporter = JiraMetricsExporter(tmp_path) + project_metrics = calculator.aggregate_project_metrics(issue_metrics, "PROJ") + person_metrics = calculator.aggregate_person_metrics(issue_metrics) + type_metrics = calculator.aggregate_type_metrics(issue_metrics) + + project_file = metrics_exporter.export_project_metrics([project_metrics]) + person_file = metrics_exporter.export_person_metrics(person_metrics) + type_file = metrics_exporter.export_type_metrics(type_metrics) + + # Assert: All files created + assert issues_file.exists() + assert project_file.exists() + assert person_file.exists() + assert type_file.exists() + + assert issues_file.name == "jira_issues_export.csv" + assert project_file.name == "jira_project_metrics.csv" + assert person_file.name == "jira_person_metrics.csv" + assert type_file.name == "jira_type_metrics.csv" + + def test_issues_export_has_all_metric_columns(self, tmp_path: Path) -> None: + """Given issues, exported CSV has all 22 columns (12 original + 10 metrics).""" + issues = [make_test_issue("PROJ-1")] + comments = [make_test_comment("1", "PROJ-1")] + + calculator = MetricsCalculator() + issue_metrics = [calculator.calculate_issue_metrics(issues[0], comments)] + + jira_exporter = JiraExporter(tmp_path) + issues_file = jira_exporter.export_issues_with_metrics(issue_metrics) + + with open(issues_file, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + columns = reader.fieldnames or [] + + # 12 original + 10 metric columns = 22 total + assert len(columns) == 22 + + # Verify metric columns present + metric_cols = [ + "cycle_time_days", "aging_days", "comments_count", + "description_quality_score", "acceptance_criteria_present", + "comment_velocity_hours", "silent_issue", "same_day_resolution", + "cross_team_score", "reopen_count", + ] + for col in metric_cols: + assert col in columns, f"Missing column: {col}" + + def test_project_metrics_aggregation_correct(self, tmp_path: Path) -> None: + """Given mix of issues, project aggregation is correct.""" + issues = [ + make_test_issue("PROJ-1", issue_type="Bug"), # Resolved + make_test_issue("PROJ-2", issue_type="Bug"), # Resolved + make_test_issue("PROJ-3", issue_type="Story"), # Resolved + make_test_issue("PROJ-4", status="Open", resolution_offset_days=None), # Open + ] + + calculator = MetricsCalculator() + issue_metrics = [ + calculator.calculate_issue_metrics(issue, []) + for issue in issues + ] + + project_metrics = calculator.aggregate_project_metrics(issue_metrics, "PROJ") + + # 4 total, 3 resolved, 1 open + assert project_metrics.total_issues == 4 + assert project_metrics.resolved_count == 3 + assert project_metrics.unresolved_count == 1 + + # 2 bugs out of 4 = 50% + assert project_metrics.bug_count == 2 + assert project_metrics.bug_ratio_percent == 50.0 + + # All issues are silent (no comments) + assert project_metrics.silent_issues_ratio_percent == 100.0 + + def test_person_metrics_excludes_unassigned(self, tmp_path: Path) -> None: + """Given unassigned issues, person metrics excludes them.""" + issues = [ + make_test_issue("PROJ-1", assignee="John"), + make_test_issue("PROJ-2", assignee=None), # Unassigned + make_test_issue("PROJ-3", assignee="Jane"), + ] + + calculator = MetricsCalculator() + issue_metrics = [ + calculator.calculate_issue_metrics(issue, []) + for issue in issues + ] + + person_metrics = calculator.aggregate_person_metrics(issue_metrics) + + # Only John and Jane should appear (not None) + names = {p.assignee_name for p in person_metrics} + assert names == {"John", "Jane"} + assert len(person_metrics) == 2 + + def test_type_metrics_by_issue_type(self, tmp_path: Path) -> None: + """Given different issue types, type metrics separates them.""" + issues = [ + make_test_issue("PROJ-1", issue_type="Bug"), + make_test_issue("PROJ-2", issue_type="Bug"), + make_test_issue("PROJ-3", issue_type="Story"), + make_test_issue("PROJ-4", issue_type="Task"), + ] + + calculator = MetricsCalculator() + issue_metrics = [ + calculator.calculate_issue_metrics(issue, []) + for issue in issues + ] + + type_metrics = calculator.aggregate_type_metrics(issue_metrics) + + # 3 types: Bug, Story, Task + types = {t.issue_type for t in type_metrics} + assert types == {"Bug", "Story", "Task"} + + bug_metrics = next(t for t in type_metrics if t.issue_type == "Bug") + assert bug_metrics.count == 2 + assert bug_metrics.bug_resolution_time_avg is not None # Bug-specific field + + story_metrics = next(t for t in type_metrics if t.issue_type == "Story") + assert story_metrics.bug_resolution_time_avg is None # Not a bug + + def test_cross_team_score_calculated(self, tmp_path: Path) -> None: + """Given comments from multiple authors, cross_team_score reflects collaboration.""" + issues = [make_test_issue("PROJ-1")] + comments = [ + make_test_comment("1", "PROJ-1", author="Alice"), + make_test_comment("2", "PROJ-1", author="Bob"), + make_test_comment("3", "PROJ-1", author="Charlie"), + ] + + calculator = MetricsCalculator() + issue_metrics = calculator.calculate_issue_metrics(issues[0], comments) + + # 3 unique authors = 75 score per CROSS_TEAM_SCALE + assert issue_metrics.cross_team_score == 75 + + def test_description_quality_with_ac(self, tmp_path: Path) -> None: + """Given well-formatted description with AC, quality score is high.""" + issue = make_test_issue( + "PROJ-1", + description="## Description\n\nDetailed description here with lots of content.\n\n## Acceptance Criteria\n\n- [ ] Criterion one\n- [x] Criterion two", + ) + + calculator = MetricsCalculator() + metrics = calculator.calculate_issue_metrics(issue, []) + + # High score: 40 length + 40 AC + up to 20 formatting + assert metrics.acceptance_criteria_present is True + assert metrics.description_quality_score >= 80 diff --git a/tests/unit/analyzers/test_jira_metrics.py b/tests/unit/analyzers/test_jira_metrics.py new file mode 100644 index 0000000..ed4c93b --- /dev/null +++ b/tests/unit/analyzers/test_jira_metrics.py @@ -0,0 +1,857 @@ +"""Unit tests for Jira quality metrics calculation. + +Tests cover: FR-001 to FR-009 (issue-level metrics), +FR-010 to FR-023 (aggregation metrics). +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from src.github_analyzer.analyzers.jira_metrics import ( + IssueMetrics, + MetricsCalculator, + PersonMetrics, + ProjectMetrics, + TypeMetrics, + calculate_aging, + calculate_comment_metrics, + calculate_cross_team_score, + calculate_cycle_time, + calculate_description_quality, + calculate_same_day_resolution, + detect_acceptance_criteria, + detect_reopens, +) +from src.github_analyzer.api.jira_client import JiraComment, JiraIssue + + +# ============================================================================= +# Helper Functions for Creating Test Objects +# ============================================================================= + + +def make_issue( + key: str = "PROJ-1", + created: datetime | None = None, + resolution_date: datetime | None = None, + description: str = "", + issue_type: str = "Story", + assignee: str | None = "John Doe", + status: str = "Open", +) -> JiraIssue: + """Create a test JiraIssue with minimal required fields.""" + if created is None: + created = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) + return JiraIssue( + key=key, + summary=f"Test issue {key}", + description=description, + status=status, + issue_type=issue_type, + priority="Medium", + assignee=assignee, + reporter="Jane Smith", + created=created, + updated=created, + resolution_date=resolution_date, + project_key="PROJ", + ) + + +def make_comment( + comment_id: str = "1", + issue_key: str = "PROJ-1", + author: str = "John Doe", + created: datetime | None = None, +) -> JiraComment: + """Create a test JiraComment.""" + if created is None: + created = datetime(2025, 11, 2, 10, 0, 0, tzinfo=timezone.utc) + return JiraComment( + id=comment_id, + issue_key=issue_key, + author=author, + created=created, + body="Test comment", + ) + + +# ============================================================================= +# T007: Tests for cycle_time calculation (FR-001) +# ============================================================================= + + +class TestCycleTime: + """Tests for calculate_cycle_time function.""" + + def test_resolved_issue_cycle_time(self) -> None: + """Given a resolved issue, calculate days between created and resolved.""" + created = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) + resolved = datetime(2025, 11, 15, 10, 0, 0, tzinfo=timezone.utc) + + result = calculate_cycle_time(created, resolved) + + assert result == 14.0 + + def test_open_issue_returns_none(self) -> None: + """Given an open issue, cycle_time should be None.""" + created = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) + + result = calculate_cycle_time(created, None) + + assert result is None + + def test_same_day_resolution(self) -> None: + """Given same-day resolution, cycle_time should be fractional days.""" + created = datetime(2025, 11, 25, 9, 0, 0, tzinfo=timezone.utc) + resolved = datetime(2025, 11, 25, 14, 0, 0, tzinfo=timezone.utc) + + result = calculate_cycle_time(created, resolved) + + # 5 hours = 5/24 ≈ 0.21 days + assert result is not None + assert 0.2 <= result <= 0.22 + + def test_negative_cycle_time_returns_none(self) -> None: + """Given resolution before creation (data error), return None with warning.""" + created = datetime(2025, 11, 15, 10, 0, 0, tzinfo=timezone.utc) + resolved = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) # Before created + + result = calculate_cycle_time(created, resolved) + + assert result is None + + def test_precision_two_decimal_places(self) -> None: + """Cycle time should have 2 decimal precision.""" + created = datetime(2025, 11, 1, 0, 0, 0, tzinfo=timezone.utc) + resolved = datetime(2025, 11, 4, 8, 0, 0, tzinfo=timezone.utc) + + result = calculate_cycle_time(created, resolved) + + # 3 days + 8 hours = 3.33... days + assert result is not None + assert result == 3.33 + + +# ============================================================================= +# T008: Tests for aging calculation (FR-002) +# ============================================================================= + + +class TestAging: + """Tests for calculate_aging function.""" + + def test_open_issue_aging(self) -> None: + """Given an open issue, calculate days since creation.""" + created = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) + now = datetime(2025, 11, 28, 10, 0, 0, tzinfo=timezone.utc) + + result = calculate_aging(created, None, now) + + assert result == 27.0 + + def test_resolved_issue_returns_none(self) -> None: + """Given a resolved issue, aging should be None.""" + created = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) + resolved = datetime(2025, 11, 15, 10, 0, 0, tzinfo=timezone.utc) + + result = calculate_aging(created, resolved) + + assert result is None + + def test_negative_aging_returns_none(self) -> None: + """Given future creation date (data error), return None with warning.""" + created = datetime(2025, 12, 15, 10, 0, 0, tzinfo=timezone.utc) # Future + now = datetime(2025, 11, 28, 10, 0, 0, tzinfo=timezone.utc) + + result = calculate_aging(created, None, now) + + assert result is None + + +# ============================================================================= +# T009: Tests for description_quality_score (FR-004) +# ============================================================================= + + +class TestDescriptionQuality: + """Tests for calculate_description_quality function.""" + + def test_empty_description_score_zero(self) -> None: + """Given empty description, score should be 0.""" + result = calculate_description_quality("", False) + assert result == 0 + + def test_short_description_partial_score(self) -> None: + """Given short description (< threshold), partial length score.""" + # 50 chars = 50/100 * 40 = 20 points + description = "A" * 50 + result = calculate_description_quality(description, False) + assert result == 20 + + def test_long_description_full_length_score(self) -> None: + """Given long description (>= threshold), full length score.""" + description = "A" * 150 # > 100 chars + result = calculate_description_quality(description, False) + assert result == 40 # Full length points + + def test_with_acceptance_criteria_adds_40_points(self) -> None: + """Given AC present, add 40 points.""" + description = "A" * 100 + result = calculate_description_quality(description, True) + assert result == 80 # 40 length + 40 AC + + def test_with_formatting_adds_points(self) -> None: + """Given headers and lists, add formatting points.""" + description = "## Header\n- List item\n" + "A" * 80 + result = calculate_description_quality(description, False) + # 40 length + 10 headers + 10 lists = 60 + assert result >= 50 # At least headers or lists detected + + def test_full_quality_score(self) -> None: + """Given long description, AC, and formatting, max score ~100.""" + description = "## Description\n- Item 1\n- Item 2\n" + "A" * 100 + result = calculate_description_quality(description, True) + assert result == 100 # 40 + 40 + 20 + + +# ============================================================================= +# T010: Tests for acceptance_criteria detection (FR-005) +# ============================================================================= + + +class TestAcceptanceCriteria: + """Tests for detect_acceptance_criteria function.""" + + def test_given_when_then_detected(self) -> None: + """Given BDD-style AC, should be detected.""" + description = "Given a user\nWhen they login\nThen they see dashboard" + assert detect_acceptance_criteria(description) is True + + def test_ac_header_detected(self) -> None: + """Given 'AC:' prefix, should be detected.""" + description = "AC: User can login" + assert detect_acceptance_criteria(description) is True + + def test_acceptance_criteria_header_detected(self) -> None: + """Given 'Acceptance Criteria:' label, should be detected.""" + description = "Acceptance Criteria: The system shall..." + assert detect_acceptance_criteria(description) is True + + def test_checkbox_list_detected(self) -> None: + """Given markdown checkbox list, should be detected.""" + description = "Tasks:\n- [ ] First task\n- [x] Second task" + assert detect_acceptance_criteria(description) is True + + def test_markdown_heading_ac_detected(self) -> None: + """Given markdown heading with AC, should be detected.""" + description = "## Acceptance Criteria\n- First criterion" + assert detect_acceptance_criteria(description) is True + + def test_no_ac_returns_false(self) -> None: + """Given description without AC patterns, should return False.""" + description = "This is just a regular description." + assert detect_acceptance_criteria(description) is False + + def test_empty_description_returns_false(self) -> None: + """Given empty description, should return False.""" + assert detect_acceptance_criteria("") is False + + +# ============================================================================= +# T011: Tests for comments_count and silent_issue (FR-003, FR-007) +# ============================================================================= + + +class TestCommentMetrics: + """Tests for calculate_comment_metrics function.""" + + def test_no_comments_silent_issue(self) -> None: + """Given no comments, issue is silent.""" + issue_created = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) + + count, velocity, silent = calculate_comment_metrics([], issue_created) + + assert count == 0 + assert velocity is None + assert silent is True + + def test_with_comments_not_silent(self) -> None: + """Given comments, issue is not silent.""" + issue_created = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) + comments = [make_comment(created=datetime(2025, 11, 2, 10, 0, 0, tzinfo=timezone.utc))] + + count, velocity, silent = calculate_comment_metrics(comments, issue_created) + + assert count == 1 + assert velocity is not None + assert silent is False + + def test_multiple_comments_count(self) -> None: + """Given multiple comments, count is correct.""" + issue_created = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) + comments = [ + make_comment("1", created=datetime(2025, 11, 2, 10, 0, 0, tzinfo=timezone.utc)), + make_comment("2", created=datetime(2025, 11, 3, 10, 0, 0, tzinfo=timezone.utc)), + make_comment("3", created=datetime(2025, 11, 4, 10, 0, 0, tzinfo=timezone.utc)), + ] + + count, _, _ = calculate_comment_metrics(comments, issue_created) + + assert count == 3 + + +# ============================================================================= +# T012: Tests for same_day_resolution (FR-008) +# ============================================================================= + + +class TestSameDayResolution: + """Tests for calculate_same_day_resolution function.""" + + def test_same_day_returns_true(self) -> None: + """Given same calendar day resolution, return True.""" + created = datetime(2025, 11, 25, 9, 0, 0, tzinfo=timezone.utc) + resolved = datetime(2025, 11, 25, 17, 0, 0, tzinfo=timezone.utc) + + result = calculate_same_day_resolution(created, resolved) + + assert result is True + + def test_different_day_returns_false(self) -> None: + """Given different day resolution, return False.""" + created = datetime(2025, 11, 25, 9, 0, 0, tzinfo=timezone.utc) + resolved = datetime(2025, 11, 26, 9, 0, 0, tzinfo=timezone.utc) + + result = calculate_same_day_resolution(created, resolved) + + assert result is False + + def test_unresolved_returns_false(self) -> None: + """Given unresolved issue, return False.""" + created = datetime(2025, 11, 25, 9, 0, 0, tzinfo=timezone.utc) + + result = calculate_same_day_resolution(created, None) + + assert result is False + + +# ============================================================================= +# T013: Tests for cross_team_score (FR-009) +# ============================================================================= + + +class TestCrossTeamScore: + """Tests for calculate_cross_team_score function.""" + + def test_no_comments_score_zero(self) -> None: + """Given no comments, score is 0.""" + result = calculate_cross_team_score([]) + assert result == 0 + + def test_one_author_score_25(self) -> None: + """Given 1 unique author, score is 25.""" + comments = [ + make_comment("1", author="John"), + make_comment("2", author="John"), + ] + result = calculate_cross_team_score(comments) + assert result == 25 + + def test_two_authors_score_50(self) -> None: + """Given 2 unique authors, score is 50.""" + comments = [ + make_comment("1", author="John"), + make_comment("2", author="Jane"), + ] + result = calculate_cross_team_score(comments) + assert result == 50 + + def test_three_authors_score_75(self) -> None: + """Given 3 unique authors, score is 75.""" + comments = [ + make_comment("1", author="John"), + make_comment("2", author="Jane"), + make_comment("3", author="Bob"), + ] + result = calculate_cross_team_score(comments) + assert result == 75 + + def test_four_authors_score_90(self) -> None: + """Given 4 unique authors, score is 90.""" + comments = [ + make_comment("1", author="John"), + make_comment("2", author="Jane"), + make_comment("3", author="Bob"), + make_comment("4", author="Alice"), + ] + result = calculate_cross_team_score(comments) + assert result == 90 + + def test_five_plus_authors_score_100(self) -> None: + """Given 5+ unique authors, score is 100.""" + comments = [ + make_comment("1", author="John"), + make_comment("2", author="Jane"), + make_comment("3", author="Bob"), + make_comment("4", author="Alice"), + make_comment("5", author="Charlie"), + ] + result = calculate_cross_team_score(comments) + assert result == 100 + + +# ============================================================================= +# T014: Tests for comment_velocity_hours (FR-006) +# ============================================================================= + + +class TestCommentVelocity: + """Tests for comment velocity calculation in calculate_comment_metrics.""" + + def test_velocity_hours_calculation(self) -> None: + """Given comments, velocity is hours from creation to first comment.""" + issue_created = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) + first_comment = datetime(2025, 11, 2, 10, 0, 0, tzinfo=timezone.utc) # 24 hours later + comments = [make_comment(created=first_comment)] + + _, velocity, _ = calculate_comment_metrics(comments, issue_created) + + assert velocity == 24.0 + + def test_velocity_uses_earliest_comment(self) -> None: + """Given multiple comments, velocity uses the earliest one.""" + issue_created = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) + comments = [ + make_comment("2", created=datetime(2025, 11, 3, 10, 0, 0, tzinfo=timezone.utc)), # 48 hours + make_comment("1", created=datetime(2025, 11, 2, 10, 0, 0, tzinfo=timezone.utc)), # 24 hours (first) + ] + + _, velocity, _ = calculate_comment_metrics(comments, issue_created) + + assert velocity == 24.0 # Uses first comment, not second + + def test_no_comments_velocity_none(self) -> None: + """Given no comments, velocity is None.""" + issue_created = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) + + _, velocity, _ = calculate_comment_metrics([], issue_created) + + assert velocity is None + + +# ============================================================================= +# T039: Tests for reopen detection (FR-022) +# ============================================================================= + + +class TestReopenDetection: + """Tests for detect_reopens function.""" + + def test_reopen_detected(self) -> None: + """Given Done→In Progress transition, count as reopen.""" + changelog = [ + { + "items": [ + {"field": "status", "fromString": "Done", "toString": "In Progress"} + ] + } + ] + + result = detect_reopens(changelog) + + assert result == 1 + + def test_multiple_reopens(self) -> None: + """Given multiple Done→non-Done transitions, count all.""" + changelog = [ + {"items": [{"field": "status", "fromString": "Done", "toString": "Open"}]}, + {"items": [{"field": "status", "fromString": "Closed", "toString": "In Progress"}]}, + ] + + result = detect_reopens(changelog) + + assert result == 2 + + def test_no_reopen(self) -> None: + """Given no Done→non-Done transitions, count is 0.""" + changelog = [ + {"items": [{"field": "status", "fromString": "Open", "toString": "In Progress"}]}, + {"items": [{"field": "status", "fromString": "In Progress", "toString": "Done"}]}, + ] + + result = detect_reopens(changelog) + + assert result == 0 + + def test_empty_changelog(self) -> None: + """Given empty changelog, count is 0.""" + result = detect_reopens([]) + assert result == 0 + + def test_non_status_changes_ignored(self) -> None: + """Given non-status field changes, ignore them.""" + changelog = [ + {"items": [{"field": "assignee", "fromString": "John", "toString": "Jane"}]}, + {"items": [{"field": "priority", "fromString": "High", "toString": "Low"}]}, + ] + + result = detect_reopens(changelog) + + assert result == 0 + + +# ============================================================================= +# T026: Tests for project aggregation (FR-010 to FR-014) +# ============================================================================= + + +class TestProjectAggregation: + """Tests for MetricsCalculator.aggregate_project_metrics.""" + + def test_avg_cycle_time(self) -> None: + """Given resolved issues, calculate average cycle time.""" + calculator = MetricsCalculator() + + issue1 = make_issue("PROJ-1", resolution_date=datetime(2025, 11, 11, 10, 0, 0, tzinfo=timezone.utc)) + issue2 = make_issue("PROJ-2", resolution_date=datetime(2025, 11, 21, 10, 0, 0, tzinfo=timezone.utc)) + + metrics = [ + IssueMetrics( + issue=issue1, cycle_time_days=10.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=issue2, cycle_time_days=20.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + ] + + result = calculator.aggregate_project_metrics(metrics, "PROJ") + + assert result.avg_cycle_time_days == 15.0 + + def test_median_cycle_time(self) -> None: + """Given resolved issues, calculate median cycle time.""" + calculator = MetricsCalculator() + + metrics = [] + for i, days in enumerate([5.0, 10.0, 15.0, 20.0, 100.0]): + issue = make_issue(f"PROJ-{i}", resolution_date=datetime(2025, 11, 15, 10, 0, 0, tzinfo=timezone.utc)) + metrics.append(IssueMetrics( + issue=issue, cycle_time_days=days, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + )) + + result = calculator.aggregate_project_metrics(metrics, "PROJ") + + assert result.median_cycle_time_days == 15.0 # Middle value + + def test_bug_ratio(self) -> None: + """Given mix of issue types, calculate bug ratio.""" + calculator = MetricsCalculator() + + metrics = [ + IssueMetrics( + issue=make_issue("PROJ-1", issue_type="Bug"), cycle_time_days=5.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=make_issue("PROJ-2", issue_type="Bug"), cycle_time_days=5.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=make_issue("PROJ-3", issue_type="Story"), cycle_time_days=5.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=make_issue("PROJ-4", issue_type="Task"), cycle_time_days=5.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + ] + + result = calculator.aggregate_project_metrics(metrics, "PROJ") + + assert result.bug_count == 2 + assert result.bug_ratio_percent == 50.0 # 2/4 * 100 + + def test_silent_issues_ratio(self) -> None: + """Given mix of silent/non-silent, calculate silent ratio.""" + calculator = MetricsCalculator() + + metrics = [ + IssueMetrics( + issue=make_issue("PROJ-1"), cycle_time_days=5.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=make_issue("PROJ-2"), cycle_time_days=5.0, aging_days=None, + comments_count=5, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=10.0, + silent_issue=False, same_day_resolution=False, cross_team_score=50, reopen_count=0, + ), + ] + + result = calculator.aggregate_project_metrics(metrics, "PROJ") + + assert result.silent_issues_ratio_percent == 50.0 + + def test_empty_project(self) -> None: + """Given no issues, return default values.""" + calculator = MetricsCalculator() + + result = calculator.aggregate_project_metrics([], "EMPTY") + + assert result.total_issues == 0 + assert result.avg_cycle_time_days is None + assert result.bug_ratio_percent == 0.0 + + +# ============================================================================= +# T031: Tests for person aggregation (FR-015 to FR-018) +# ============================================================================= + + +class TestPersonAggregation: + """Tests for MetricsCalculator.aggregate_person_metrics.""" + + def test_wip_count(self) -> None: + """Given open issues, calculate WIP for assignee.""" + calculator = MetricsCalculator() + + metrics = [ + IssueMetrics( + issue=make_issue("PROJ-1", assignee="John Doe"), cycle_time_days=None, aging_days=10.0, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=make_issue("PROJ-2", assignee="John Doe"), cycle_time_days=None, aging_days=5.0, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=make_issue("PROJ-3", assignee="John Doe", resolution_date=datetime(2025, 11, 15, 10, 0, 0, tzinfo=timezone.utc)), + cycle_time_days=14.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + ] + + result = calculator.aggregate_person_metrics(metrics) + + assert len(result) == 1 + person = result[0] + assert person.assignee_name == "John Doe" + assert person.wip_count == 2 + assert person.resolved_count == 1 + assert person.total_assigned == 3 + + def test_unassigned_excluded(self) -> None: + """Given unassigned issues, they should be excluded.""" + calculator = MetricsCalculator() + + metrics = [ + IssueMetrics( + issue=make_issue("PROJ-1", assignee=None), cycle_time_days=None, aging_days=10.0, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=make_issue("PROJ-2", assignee="John Doe"), cycle_time_days=5.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + ] + + result = calculator.aggregate_person_metrics(metrics) + + assert len(result) == 1 + assert result[0].assignee_name == "John Doe" + + def test_multiple_assignees(self) -> None: + """Given multiple assignees, return metrics for each.""" + calculator = MetricsCalculator() + + metrics = [ + IssueMetrics( + issue=make_issue("PROJ-1", assignee="John"), cycle_time_days=5.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=make_issue("PROJ-2", assignee="Jane"), cycle_time_days=10.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + ] + + result = calculator.aggregate_person_metrics(metrics) + + assert len(result) == 2 + names = {p.assignee_name for p in result} + assert names == {"John", "Jane"} + + +# ============================================================================= +# T035: Tests for type aggregation (FR-019 to FR-021) +# ============================================================================= + + +class TestTypeAggregation: + """Tests for MetricsCalculator.aggregate_type_metrics.""" + + def test_per_type_counts(self) -> None: + """Given mix of types, count each type.""" + calculator = MetricsCalculator() + + metrics = [ + IssueMetrics( + issue=make_issue("PROJ-1", issue_type="Bug"), cycle_time_days=5.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=make_issue("PROJ-2", issue_type="Bug"), cycle_time_days=10.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=make_issue("PROJ-3", issue_type="Story"), cycle_time_days=15.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + ] + + result = calculator.aggregate_type_metrics(metrics) + + bug_metrics = next(t for t in result if t.issue_type == "Bug") + story_metrics = next(t for t in result if t.issue_type == "Story") + + assert bug_metrics.count == 2 + assert story_metrics.count == 1 + + def test_bug_resolution_time_only_for_bugs(self) -> None: + """bug_resolution_time_avg is only set for Bug type.""" + calculator = MetricsCalculator() + + metrics = [ + IssueMetrics( + issue=make_issue("PROJ-1", issue_type="Bug"), cycle_time_days=5.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=make_issue("PROJ-2", issue_type="Story"), cycle_time_days=10.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + ] + + result = calculator.aggregate_type_metrics(metrics) + + bug_metrics = next(t for t in result if t.issue_type == "Bug") + story_metrics = next(t for t in result if t.issue_type == "Story") + + assert bug_metrics.bug_resolution_time_avg == 5.0 + assert story_metrics.bug_resolution_time_avg is None + + def test_avg_cycle_time_per_type(self) -> None: + """Given resolved issues of same type, calculate avg cycle time.""" + calculator = MetricsCalculator() + + metrics = [ + IssueMetrics( + issue=make_issue("PROJ-1", issue_type="Bug"), cycle_time_days=4.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=make_issue("PROJ-2", issue_type="Bug"), cycle_time_days=6.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + ] + + result = calculator.aggregate_type_metrics(metrics) + + bug_metrics = next(t for t in result if t.issue_type == "Bug") + assert bug_metrics.avg_cycle_time_days == 5.0 + + +# ============================================================================= +# T041: Tests for reopen_rate_percent in project aggregation (FR-023) +# ============================================================================= + + +class TestReopenRateAggregation: + """Tests for reopen_rate_percent in project metrics.""" + + def test_reopen_rate_calculation(self) -> None: + """Given reopened issues, calculate rate correctly.""" + calculator = MetricsCalculator() + + metrics = [ + IssueMetrics( + issue=make_issue("PROJ-1"), cycle_time_days=5.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=1, + ), + IssueMetrics( + issue=make_issue("PROJ-2"), cycle_time_days=5.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + IssueMetrics( + issue=make_issue("PROJ-3"), cycle_time_days=5.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=2, # 2 reopens + ), + IssueMetrics( + issue=make_issue("PROJ-4"), cycle_time_days=5.0, aging_days=None, + comments_count=0, description_quality_score=50, + acceptance_criteria_present=False, comment_velocity_hours=None, + silent_issue=True, same_day_resolution=False, cross_team_score=0, reopen_count=0, + ), + ] + + result = calculator.aggregate_project_metrics(metrics, "PROJ") + + # 2 out of 4 resolved issues were reopened = 50% + assert result.reopen_rate_percent == 50.0 diff --git a/tests/unit/api/test_jira_client.py b/tests/unit/api/test_jira_client.py index 6cfb8e6..dfd1fc7 100644 --- a/tests/unit/api/test_jira_client.py +++ b/tests/unit/api/test_jira_client.py @@ -1104,3 +1104,75 @@ def test_jira_comment_creation(self) -> None: assert comment.id == "10001" assert comment.issue_key == "PROJ-1" assert comment.body == "This is a comment." + + +# ============================================================================= +# T040: Tests for changelog API (FR-022) +# ============================================================================= + + +class TestGetIssueChangelog: + """Tests for JiraClient.get_issue_changelog.""" + + def test_get_changelog_success(self, jira_config: JiraConfig) -> None: + """Given valid issue, return changelog entries.""" + from src.github_analyzer.api.jira_client import JiraClient + from tests.fixtures.jira_responses import CHANGELOG_WITH_REOPEN + + client = JiraClient(jira_config) + + with mock.patch.object(client, "_make_request") as mock_request: + mock_request.return_value = CHANGELOG_WITH_REOPEN + result = client.get_issue_changelog("PROJ-123") + + assert len(result) == 4 # 4 status transitions in fixture + assert result[0]["items"][0]["field"] == "status" + + def test_get_changelog_403_returns_empty(self, jira_config: JiraConfig) -> None: + """Given 403 permission error, return empty list (graceful degradation).""" + from src.github_analyzer.api.jira_client import JiraClient + + client = JiraClient(jira_config) + + with mock.patch.object(client, "_make_request") as mock_request: + mock_request.side_effect = JiraPermissionError("Access denied") + result = client.get_issue_changelog("PROJ-123") + + assert result == [] + + def test_get_changelog_404_returns_empty(self, jira_config: JiraConfig) -> None: + """Given 404 not found, return empty list (graceful degradation).""" + from src.github_analyzer.api.jira_client import JiraClient + + client = JiraClient(jira_config) + + with mock.patch.object(client, "_make_request") as mock_request: + mock_request.side_effect = JiraNotFoundError("Issue not found") + result = client.get_issue_changelog("PROJ-123") + + assert result == [] + + def test_get_changelog_empty_response(self, jira_config: JiraConfig) -> None: + """Given empty changelog, return empty list.""" + from src.github_analyzer.api.jira_client import JiraClient + from tests.fixtures.jira_responses import CHANGELOG_EMPTY + + client = JiraClient(jira_config) + + with mock.patch.object(client, "_make_request") as mock_request: + mock_request.return_value = CHANGELOG_EMPTY + result = client.get_issue_changelog("PROJ-123") + + assert result == [] + + def test_get_changelog_other_api_error_propagates(self, jira_config: JiraConfig) -> None: + """Given other API error (500), propagate exception.""" + from src.github_analyzer.api.jira_client import JiraClient + + client = JiraClient(jira_config) + + with mock.patch.object(client, "_make_request") as mock_request: + mock_request.side_effect = JiraAPIError("Server error", status_code=500) + + with pytest.raises(JiraAPIError): + client.get_issue_changelog("PROJ-123") diff --git a/tests/unit/exporters/test_jira_exporter.py b/tests/unit/exporters/test_jira_exporter.py index a9ed42e..269f7f4 100644 --- a/tests/unit/exporters/test_jira_exporter.py +++ b/tests/unit/exporters/test_jira_exporter.py @@ -467,3 +467,221 @@ def test_exports_many_comments_efficiently(self, tmp_path: Path) -> None: rows = list(reader) assert len(rows) == 1000 + + +# ============================================================================= +# T015: Tests for extended CSV export columns with metrics (Feature 003) +# ============================================================================= + + +class TestExtendedIssueExport: + """Tests for export_issues_with_metrics method (FR-003 extended export).""" + + def test_exports_all_metric_columns(self, tmp_path: Path) -> None: + """Extended export includes all 10 new metric columns.""" + from src.github_analyzer.analyzers.jira_metrics import IssueMetrics + from src.github_analyzer.exporters.jira_exporter import ( + EXTENDED_ISSUE_COLUMNS, + JiraExporter, + ) + + now = datetime(2025, 11, 15, 10, 0, 0, tzinfo=timezone.utc) + issue = JiraIssue( + key="PROJ-1", + summary="Test issue", + description="Test description", + status="Done", + issue_type="Story", + priority="High", + assignee="John Doe", + reporter="Jane Smith", + created=datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc), + updated=now, + resolution_date=now, + project_key="PROJ", + ) + + metrics = IssueMetrics( + issue=issue, + cycle_time_days=14.0, + aging_days=None, + comments_count=5, + description_quality_score=75, + acceptance_criteria_present=True, + comment_velocity_hours=24.5, + silent_issue=False, + same_day_resolution=False, + cross_team_score=75, + reopen_count=1, + ) + + exporter = JiraExporter(tmp_path) + result = exporter.export_issues_with_metrics([metrics]) + + with open(result, encoding="utf-8") as f: + reader = csv.DictReader(f) + assert reader.fieldnames == list(EXTENDED_ISSUE_COLUMNS) + + def test_metric_values_correct_format(self, tmp_path: Path) -> None: + """Metric values are formatted correctly (2 decimal floats, lowercase booleans).""" + from src.github_analyzer.analyzers.jira_metrics import IssueMetrics + from src.github_analyzer.exporters.jira_exporter import JiraExporter + + now = datetime(2025, 11, 15, 10, 0, 0, tzinfo=timezone.utc) + issue = JiraIssue( + key="PROJ-1", + summary="Test issue", + description="Test description", + status="Done", + issue_type="Story", + priority="High", + assignee="John Doe", + reporter="Jane Smith", + created=datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc), + updated=now, + resolution_date=now, + project_key="PROJ", + ) + + metrics = IssueMetrics( + issue=issue, + cycle_time_days=14.25, + aging_days=None, + comments_count=5, + description_quality_score=75, + acceptance_criteria_present=True, + comment_velocity_hours=24.5, + silent_issue=False, + same_day_resolution=False, + cross_team_score=75, + reopen_count=1, + ) + + exporter = JiraExporter(tmp_path) + result = exporter.export_issues_with_metrics([metrics]) + + with open(result, encoding="utf-8") as f: + reader = csv.DictReader(f) + row = next(reader) + + # Check float format (2 decimals) + assert row["cycle_time_days"] == "14.25" + assert row["comment_velocity_hours"] == "24.50" + + # Check integer format + assert row["comments_count"] == "5" + assert row["description_quality_score"] == "75" + assert row["cross_team_score"] == "75" + assert row["reopen_count"] == "1" + + # Check boolean format (lowercase) + assert row["acceptance_criteria_present"] == "true" + assert row["silent_issue"] == "false" + assert row["same_day_resolution"] == "false" + + def test_none_values_as_empty_string(self, tmp_path: Path) -> None: + """None metric values are exported as empty strings.""" + from src.github_analyzer.analyzers.jira_metrics import IssueMetrics + from src.github_analyzer.exporters.jira_exporter import JiraExporter + + now = datetime(2025, 11, 15, 10, 0, 0, tzinfo=timezone.utc) + issue = JiraIssue( + key="PROJ-1", + summary="Open issue", + description="", + status="Open", + issue_type="Task", + priority=None, + assignee=None, + reporter="Jane Smith", + created=datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc), + updated=now, + resolution_date=None, + project_key="PROJ", + ) + + metrics = IssueMetrics( + issue=issue, + cycle_time_days=None, # Open issue, no cycle time + aging_days=14.0, + comments_count=0, + description_quality_score=0, + acceptance_criteria_present=False, + comment_velocity_hours=None, # Silent issue + silent_issue=True, + same_day_resolution=False, + cross_team_score=0, + reopen_count=0, + ) + + exporter = JiraExporter(tmp_path) + result = exporter.export_issues_with_metrics([metrics]) + + with open(result, encoding="utf-8") as f: + reader = csv.DictReader(f) + row = next(reader) + + # None values should be empty strings + assert row["cycle_time_days"] == "" + assert row["comment_velocity_hours"] == "" + assert row["priority"] == "" + assert row["assignee"] == "" + + # Aging should have value + assert row["aging_days"] == "14.00" + + def test_preserves_original_columns(self, tmp_path: Path) -> None: + """Extended export preserves all original issue columns.""" + from src.github_analyzer.analyzers.jira_metrics import IssueMetrics + from src.github_analyzer.exporters.jira_exporter import JiraExporter + + created = datetime(2025, 11, 1, 10, 0, 0, tzinfo=timezone.utc) + resolved = datetime(2025, 11, 15, 16, 0, 0, tzinfo=timezone.utc) + issue = JiraIssue( + key="PROJ-123", + summary="Test summary", + description="Test description with details", + status="Done", + issue_type="Bug", + priority="Critical", + assignee="Alice Johnson", + reporter="Bob Wilson", + created=created, + updated=resolved, + resolution_date=resolved, + project_key="MYPROJ", + ) + + metrics = IssueMetrics( + issue=issue, + cycle_time_days=14.25, + aging_days=None, + comments_count=3, + description_quality_score=60, + acceptance_criteria_present=False, + comment_velocity_hours=12.0, + silent_issue=False, + same_day_resolution=False, + cross_team_score=50, + reopen_count=0, + ) + + exporter = JiraExporter(tmp_path) + result = exporter.export_issues_with_metrics([metrics]) + + with open(result, encoding="utf-8") as f: + reader = csv.DictReader(f) + row = next(reader) + + # Check original columns preserved + assert row["key"] == "PROJ-123" + assert row["summary"] == "Test summary" + assert row["description"] == "Test description with details" + assert row["status"] == "Done" + assert row["issue_type"] == "Bug" + assert row["priority"] == "Critical" + assert row["assignee"] == "Alice Johnson" + assert row["reporter"] == "Bob Wilson" + assert row["project_key"] == "MYPROJ" + assert "2025-11-01" in row["created"] + assert "2025-11-15" in row["resolution_date"] diff --git a/tests/unit/exporters/test_jira_metrics_exporter.py b/tests/unit/exporters/test_jira_metrics_exporter.py new file mode 100644 index 0000000..dfad970 --- /dev/null +++ b/tests/unit/exporters/test_jira_metrics_exporter.py @@ -0,0 +1,392 @@ +"""Unit tests for Jira metrics CSV exporters. + +Tests cover: T027, T032, T036 (project, person, type metrics exports). +""" + +from __future__ import annotations + +import csv +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from src.github_analyzer.analyzers.jira_metrics import ( + PersonMetrics, + ProjectMetrics, + TypeMetrics, +) +from src.github_analyzer.exporters.jira_metrics_exporter import ( + JiraMetricsExporter, + PERSON_COLUMNS, + PROJECT_COLUMNS, + TYPE_COLUMNS, +) + + +# ============================================================================= +# T027: Tests for project metrics CSV export +# ============================================================================= + + +class TestProjectMetricsExport: + """Tests for JiraMetricsExporter.export_project_metrics.""" + + def test_creates_correct_file(self, tmp_path: Path) -> None: + """Given project metrics, create jira_project_metrics.csv.""" + exporter = JiraMetricsExporter(tmp_path) + metrics = ProjectMetrics( + project_key="PROJ", + total_issues=100, + resolved_count=80, + unresolved_count=20, + avg_cycle_time_days=7.5, + median_cycle_time_days=5.0, + bug_count=25, + bug_ratio_percent=25.0, + same_day_resolution_rate_percent=10.0, + avg_description_quality=70.0, + silent_issues_ratio_percent=15.0, + avg_comments_per_issue=3.5, + avg_comment_velocity_hours=4.0, + reopen_rate_percent=5.0, + ) + + result = exporter.export_project_metrics([metrics]) + + assert result.name == "jira_project_metrics.csv" + assert result.exists() + + def test_correct_columns(self, tmp_path: Path) -> None: + """Given export, CSV has all 14 columns.""" + exporter = JiraMetricsExporter(tmp_path) + metrics = ProjectMetrics( + project_key="PROJ", + total_issues=100, + resolved_count=80, + unresolved_count=20, + avg_cycle_time_days=7.5, + median_cycle_time_days=5.0, + bug_count=25, + bug_ratio_percent=25.0, + same_day_resolution_rate_percent=10.0, + avg_description_quality=70.0, + silent_issues_ratio_percent=15.0, + avg_comments_per_issue=3.5, + avg_comment_velocity_hours=4.0, + reopen_rate_percent=5.0, + ) + + result = exporter.export_project_metrics([metrics]) + + with open(result, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + fieldnames = reader.fieldnames or [] + + assert len(fieldnames) == 14 + assert fieldnames == list(PROJECT_COLUMNS) + + def test_float_formatting(self, tmp_path: Path) -> None: + """Given float values, format with 2 decimal places.""" + exporter = JiraMetricsExporter(tmp_path) + metrics = ProjectMetrics( + project_key="PROJ", + total_issues=100, + resolved_count=80, + unresolved_count=20, + avg_cycle_time_days=7.556, # Should round to 7.56 + median_cycle_time_days=5.0, + bug_count=25, + bug_ratio_percent=25.0, + same_day_resolution_rate_percent=10.0, + avg_description_quality=70.0, + silent_issues_ratio_percent=15.0, + avg_comments_per_issue=3.5, + avg_comment_velocity_hours=4.0, + reopen_rate_percent=5.0, + ) + + result = exporter.export_project_metrics([metrics]) + + with open(result, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + row = next(reader) + + assert row["avg_cycle_time_days"] == "7.56" + + def test_none_values_empty_string(self, tmp_path: Path) -> None: + """Given None values, export as empty string.""" + exporter = JiraMetricsExporter(tmp_path) + metrics = ProjectMetrics( + project_key="PROJ", + total_issues=0, + resolved_count=0, + unresolved_count=0, + avg_cycle_time_days=None, + median_cycle_time_days=None, + bug_count=0, + bug_ratio_percent=0.0, + same_day_resolution_rate_percent=0.0, + avg_description_quality=0.0, + silent_issues_ratio_percent=0.0, + avg_comments_per_issue=0.0, + avg_comment_velocity_hours=None, + reopen_rate_percent=0.0, + ) + + result = exporter.export_project_metrics([metrics]) + + with open(result, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + row = next(reader) + + assert row["avg_cycle_time_days"] == "" + assert row["avg_comment_velocity_hours"] == "" + + def test_multiple_projects(self, tmp_path: Path) -> None: + """Given multiple projects, export all rows.""" + exporter = JiraMetricsExporter(tmp_path) + metrics = [ + ProjectMetrics( + project_key="PROJ1", + total_issues=50, + resolved_count=40, + unresolved_count=10, + avg_cycle_time_days=5.0, + median_cycle_time_days=4.0, + bug_count=10, + bug_ratio_percent=20.0, + same_day_resolution_rate_percent=5.0, + avg_description_quality=60.0, + silent_issues_ratio_percent=10.0, + avg_comments_per_issue=2.0, + avg_comment_velocity_hours=3.0, + reopen_rate_percent=2.0, + ), + ProjectMetrics( + project_key="PROJ2", + total_issues=100, + resolved_count=80, + unresolved_count=20, + avg_cycle_time_days=10.0, + median_cycle_time_days=8.0, + bug_count=30, + bug_ratio_percent=30.0, + same_day_resolution_rate_percent=15.0, + avg_description_quality=75.0, + silent_issues_ratio_percent=8.0, + avg_comments_per_issue=4.0, + avg_comment_velocity_hours=2.0, + reopen_rate_percent=3.0, + ), + ] + + result = exporter.export_project_metrics(metrics) + + with open(result, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + + assert len(rows) == 2 + assert rows[0]["project_key"] == "PROJ1" + assert rows[1]["project_key"] == "PROJ2" + + +# ============================================================================= +# T032: Tests for person metrics CSV export +# ============================================================================= + + +class TestPersonMetricsExport: + """Tests for JiraMetricsExporter.export_person_metrics.""" + + def test_creates_correct_file(self, tmp_path: Path) -> None: + """Given person metrics, create jira_person_metrics.csv.""" + exporter = JiraMetricsExporter(tmp_path) + metrics = PersonMetrics( + assignee_name="John Doe", + wip_count=5, + resolved_count=25, + total_assigned=30, + avg_cycle_time_days=6.75, + bug_count_assigned=8, + ) + + result = exporter.export_person_metrics([metrics]) + + assert result.name == "jira_person_metrics.csv" + assert result.exists() + + def test_correct_columns(self, tmp_path: Path) -> None: + """Given export, CSV has all 6 columns.""" + exporter = JiraMetricsExporter(tmp_path) + metrics = PersonMetrics( + assignee_name="John Doe", + wip_count=5, + resolved_count=25, + total_assigned=30, + avg_cycle_time_days=6.75, + bug_count_assigned=8, + ) + + result = exporter.export_person_metrics([metrics]) + + with open(result, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + fieldnames = reader.fieldnames or [] + + assert len(fieldnames) == 6 + assert fieldnames == list(PERSON_COLUMNS) + + def test_avg_cycle_time_none(self, tmp_path: Path) -> None: + """Given no resolved issues, avg_cycle_time is empty.""" + exporter = JiraMetricsExporter(tmp_path) + metrics = PersonMetrics( + assignee_name="John Doe", + wip_count=5, + resolved_count=0, + total_assigned=5, + avg_cycle_time_days=None, + bug_count_assigned=0, + ) + + result = exporter.export_person_metrics([metrics]) + + with open(result, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + row = next(reader) + + assert row["avg_cycle_time_days"] == "" + + def test_multiple_persons(self, tmp_path: Path) -> None: + """Given multiple persons, export all rows.""" + exporter = JiraMetricsExporter(tmp_path) + metrics = [ + PersonMetrics( + assignee_name="John Doe", + wip_count=5, + resolved_count=25, + total_assigned=30, + avg_cycle_time_days=6.75, + bug_count_assigned=8, + ), + PersonMetrics( + assignee_name="Jane Smith", + wip_count=3, + resolved_count=40, + total_assigned=43, + avg_cycle_time_days=5.5, + bug_count_assigned=12, + ), + ] + + result = exporter.export_person_metrics(metrics) + + with open(result, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + + assert len(rows) == 2 + names = {row["assignee_name"] for row in rows} + assert names == {"John Doe", "Jane Smith"} + + +# ============================================================================= +# T036: Tests for type metrics CSV export +# ============================================================================= + + +class TestTypeMetricsExport: + """Tests for JiraMetricsExporter.export_type_metrics.""" + + def test_creates_correct_file(self, tmp_path: Path) -> None: + """Given type metrics, create jira_type_metrics.csv.""" + exporter = JiraMetricsExporter(tmp_path) + metrics = TypeMetrics( + issue_type="Bug", + count=45, + resolved_count=40, + avg_cycle_time_days=4.5, + bug_resolution_time_avg=4.5, + ) + + result = exporter.export_type_metrics([metrics]) + + assert result.name == "jira_type_metrics.csv" + assert result.exists() + + def test_correct_columns(self, tmp_path: Path) -> None: + """Given export, CSV has all 5 columns.""" + exporter = JiraMetricsExporter(tmp_path) + metrics = TypeMetrics( + issue_type="Bug", + count=45, + resolved_count=40, + avg_cycle_time_days=4.5, + bug_resolution_time_avg=4.5, + ) + + result = exporter.export_type_metrics([metrics]) + + with open(result, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + fieldnames = reader.fieldnames or [] + + assert len(fieldnames) == 5 + assert fieldnames == list(TYPE_COLUMNS) + + def test_bug_resolution_empty_for_non_bug(self, tmp_path: Path) -> None: + """Given non-Bug type, bug_resolution_time_avg is empty.""" + exporter = JiraMetricsExporter(tmp_path) + metrics = TypeMetrics( + issue_type="Story", + count=60, + resolved_count=50, + avg_cycle_time_days=8.25, + bug_resolution_time_avg=None, + ) + + result = exporter.export_type_metrics([metrics]) + + with open(result, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + row = next(reader) + + assert row["bug_resolution_time_avg"] == "" + + def test_multiple_types(self, tmp_path: Path) -> None: + """Given multiple types, export all rows.""" + exporter = JiraMetricsExporter(tmp_path) + metrics = [ + TypeMetrics( + issue_type="Bug", + count=45, + resolved_count=40, + avg_cycle_time_days=4.5, + bug_resolution_time_avg=4.5, + ), + TypeMetrics( + issue_type="Story", + count=60, + resolved_count=50, + avg_cycle_time_days=8.25, + bug_resolution_time_avg=None, + ), + TypeMetrics( + issue_type="Task", + count=35, + resolved_count=25, + avg_cycle_time_days=3.0, + bug_resolution_time_avg=None, + ), + ] + + result = exporter.export_type_metrics(metrics) + + with open(result, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + + assert len(rows) == 3 + types = {row["issue_type"] for row in rows} + assert types == {"Bug", "Story", "Task"}