colenio-jira-cli 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- colenio_jira_cli-0.1.2.dist-info/METADATA +184 -0
- colenio_jira_cli-0.1.2.dist-info/RECORD +12 -0
- colenio_jira_cli-0.1.2.dist-info/WHEEL +4 -0
- colenio_jira_cli-0.1.2.dist-info/entry_points.txt +2 -0
- colenio_jira_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
- jira_cli/__init__.py +4 -0
- jira_cli/cli.py +262 -0
- jira_cli/client.py +141 -0
- jira_cli/dotenv.py +83 -0
- jira_cli/models.py +108 -0
- jira_cli/query.py +95 -0
- jira_cli/render.py +83 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: colenio-jira-cli
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Modular Jira CLI tool for issue listing, filtering, and management
|
|
5
|
+
Author-email: Colenio <dev@colenio.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: click>=8.1.0
|
|
10
|
+
Requires-Dist: jira>=3.8.0
|
|
11
|
+
Requires-Dist: pydantic>=2.0.0
|
|
12
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
13
|
+
Requires-Dist: rich>=13.0.0
|
|
14
|
+
Requires-Dist: tabulate>=0.9.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: black>=24.0.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff>=0.2.0; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# Jira CLI
|
|
22
|
+
|
|
23
|
+
> Modular, class-based Jira command-line tool for issue listing, searching, and management.
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- **List issues** by project with optional filtering (status, assignee, labels)
|
|
28
|
+
- **Search** with custom JQL queries
|
|
29
|
+
- **Find** issues by text in summary/description
|
|
30
|
+
- **View** issue details with comments
|
|
31
|
+
- **Assign** issues to users
|
|
32
|
+
- **Transition** issues to new status
|
|
33
|
+
- **Multiple output formats**: table, JSON, CSV, Markdown
|
|
34
|
+
- **dotenv support**: Load credentials from `.env` or `local.env` in CWD or parent directories
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
### Via uv (local development)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
cd colenio/tools/jira-cli
|
|
42
|
+
uv sync
|
|
43
|
+
uv run jira-cli list --help
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Via uvx (remote/published)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uvx colenio-jira-cli list --project PROJ
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Setup
|
|
53
|
+
|
|
54
|
+
Create a `.env` or `local.env` file in your working directory:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Required
|
|
58
|
+
JIRA_URL=https://company.atlassian.net
|
|
59
|
+
JIRA_EMAIL=user@example.com
|
|
60
|
+
JIRA_API_TOKEN=your_api_token_here
|
|
61
|
+
|
|
62
|
+
# Optional
|
|
63
|
+
JIRA_PROJECT=PROJ # Default project for list/find
|
|
64
|
+
JIRA_PROJECT_KEY=PROJ # Backward-compatible fallback
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Getting your Jira API Token
|
|
68
|
+
|
|
69
|
+
1. Log in to your Jira Cloud instance
|
|
70
|
+
2. Go to Account Settings → Security
|
|
71
|
+
3. Click "Create API Token"
|
|
72
|
+
4. Copy the token and save to `.env`
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
### List issues
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Basic listing
|
|
80
|
+
jira-cli list --project PROJ
|
|
81
|
+
|
|
82
|
+
# With filters
|
|
83
|
+
jira-cli list --project PROJ --status "In Progress" --assignee "john@example.com"
|
|
84
|
+
|
|
85
|
+
# With custom JQL
|
|
86
|
+
jira-cli list --project PROJ --jql 'priority = High'
|
|
87
|
+
|
|
88
|
+
# Different output formats
|
|
89
|
+
jira-cli list --project PROJ --format json
|
|
90
|
+
jira-cli list --project PROJ --format csv > issues.csv
|
|
91
|
+
jira-cli list --project PROJ --format md
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Search with JQL
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
jira-cli search 'project = PROJ AND status = "To Do" AND assignee is EMPTY'
|
|
98
|
+
jira-cli search 'text ~ "urgent"' --format json
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Find by text
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
jira-cli find --project PROJ "database migration"
|
|
105
|
+
jira-cli find --project PROJ "performance issue" --max-results 100
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### View issue details
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
jira-cli view PROJ-123
|
|
112
|
+
jira-cli view PROJ-456 --comments
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Assign issue
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
jira-cli assign PROJ-789 john@example.com
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Transition issue
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
jira-cli transition PROJ-999 "In Progress" --comment "Starting work"
|
|
125
|
+
jira-cli transition PROJ-999 "Done"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Architecture
|
|
129
|
+
|
|
130
|
+
- **`client.py`**: `JiraClient` class for REST API calls, auth, and endpoints
|
|
131
|
+
- **`query.py`**: `JiraQuery` class for JQL building and search logic
|
|
132
|
+
- **`render.py`**: `JiraRenderer` class for output formatting (table, JSON, CSV, Markdown)
|
|
133
|
+
- **`models.py`**: Pydantic models for Jira structures (`JiraIssue`, `JiraSearchResult`, `IssueRow`)
|
|
134
|
+
- **`dotenv.py`**: `DotEnv` class for robust `.env`/`local.env` loading
|
|
135
|
+
- **`cli.py`**: Click CLI commands (list, search, find, view, assign, transition)
|
|
136
|
+
|
|
137
|
+
All code uses proper classes, no helper functions. Follows informix-migration patterns.
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# Install dev dependencies
|
|
143
|
+
uv sync --all-groups
|
|
144
|
+
|
|
145
|
+
# Run tests
|
|
146
|
+
uv run pytest
|
|
147
|
+
|
|
148
|
+
# Format and lint
|
|
149
|
+
uv run black jira_cli
|
|
150
|
+
uv run ruff check jira_cli
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Distribution
|
|
154
|
+
|
|
155
|
+
This tool is designed for publishing via uvx (like `helmrelease-verifier`):
|
|
156
|
+
|
|
157
|
+
1. Push to GitHub at `colenio/jira-cli`
|
|
158
|
+
2. Configure PyPI Trusted Publisher for this repository
|
|
159
|
+
3. Tag with version: `git tag v0.1.0` and push tag (`git push origin v0.1.0`)
|
|
160
|
+
4. GitHub Action `.github/workflows/release.yml` builds, tests, and publishes to PyPI
|
|
161
|
+
5. Package can be invoked anywhere with: `uvx colenio-jira-cli list --project PROJ`
|
|
162
|
+
|
|
163
|
+
## PowerShell Integration
|
|
164
|
+
|
|
165
|
+
Add to `$PROFILE` (e.g., via dotfiles/aliases.ps1):
|
|
166
|
+
|
|
167
|
+
```powershell
|
|
168
|
+
function jira-cli {
|
|
169
|
+
uvx colenio-jira-cli @args
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# Or use directly
|
|
173
|
+
alias jira = 'uvx colenio-jira-cli'
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Then:
|
|
177
|
+
|
|
178
|
+
```powershell
|
|
179
|
+
jira list --project PROJ
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
jira_cli/__init__.py,sha256=a7vK6K4Hr0zG6rFSo9htjiWEze3W2eoCZJfnrdBEToo,123
|
|
2
|
+
jira_cli/cli.py,sha256=hYmEeOhqbz6dILdPq0xLI3TxsiN3qQG_7jSeyQ2lcWE,9490
|
|
3
|
+
jira_cli/client.py,sha256=ChBaM7WX7n9rtGLDu6d4mTX5M_KCL3VpoeWpy-zP8N4,4407
|
|
4
|
+
jira_cli/dotenv.py,sha256=SUZlUwLlLkywTUdCQOK3iC6uhf4uwlqCa8CGvc0_xdw,3091
|
|
5
|
+
jira_cli/models.py,sha256=eubkzuVyEHIqX_ukQNSqdbRh74rx9cnzq-jREjch1QE,2762
|
|
6
|
+
jira_cli/query.py,sha256=ojVtgHFJNeICCpXoRUcokOTLnVBpVaRuyc1hj6nKGWg,3078
|
|
7
|
+
jira_cli/render.py,sha256=VyKw2YpLUluw_24x1avxFPaa1zr79ivzD4yUMcXj-xQ,2551
|
|
8
|
+
colenio_jira_cli-0.1.2.dist-info/METADATA,sha256=ZdoHS3NkXxZumYlXfm1autFMWipTgIhIzUVIQCz7w4A,4393
|
|
9
|
+
colenio_jira_cli-0.1.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
colenio_jira_cli-0.1.2.dist-info/entry_points.txt,sha256=cpetCgBWsefHQDZPMS5bK4LydGi8ViZDZjhDB3hlGYw,47
|
|
11
|
+
colenio_jira_cli-0.1.2.dist-info/licenses/LICENSE,sha256=SDjwwYevDcHcjk3RCRfWv2HJuny_pK6VzFfqTZ_HW3o,1064
|
|
12
|
+
colenio_jira_cli-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Colenio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
jira_cli/__init__.py
ADDED
jira_cli/cli.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Click CLI commands."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from .client import JiraClient
|
|
9
|
+
from .dotenv import DotEnv
|
|
10
|
+
from .query import JiraQuery
|
|
11
|
+
from .render import JiraRenderer
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group()
|
|
15
|
+
@click.version_option(version="0.1.0")
|
|
16
|
+
def cli():
|
|
17
|
+
"""Modular Jira CLI — list, search, view, and manage Jira issues."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_jira_client(base_url: Optional[str] = None, email: Optional[str] = None, api_token: Optional[str] = None) -> JiraClient:
|
|
22
|
+
"""Load Jira credentials from env/dotenv and create client."""
|
|
23
|
+
# Load from .env or local.env first (local.env takes precedence)
|
|
24
|
+
env = DotEnv(verbose=False)
|
|
25
|
+
env.load()
|
|
26
|
+
|
|
27
|
+
# Allow override via parameters or env
|
|
28
|
+
base_url = base_url or os.environ.get("JIRA_URL") or os.environ.get("JIRA_BASE_URL")
|
|
29
|
+
email = email or os.environ.get("JIRA_EMAIL") or os.environ.get("JIRA_USER")
|
|
30
|
+
api_token = api_token or os.environ.get("JIRA_API_TOKEN") or os.environ.get("JIRA_TOKEN")
|
|
31
|
+
|
|
32
|
+
if not all([base_url, email, api_token]):
|
|
33
|
+
missing = []
|
|
34
|
+
if not base_url:
|
|
35
|
+
missing.append("JIRA_URL or JIRA_BASE_URL")
|
|
36
|
+
if not email:
|
|
37
|
+
missing.append("JIRA_EMAIL or JIRA_USER")
|
|
38
|
+
if not api_token:
|
|
39
|
+
missing.append("JIRA_API_TOKEN or JIRA_TOKEN")
|
|
40
|
+
|
|
41
|
+
click.echo(f"❌ Missing: {', '.join(missing)}", err=True)
|
|
42
|
+
click.echo("Configure in .env or local.env in CWD or parent directories:", err=True)
|
|
43
|
+
click.echo(" JIRA_URL=https://company.atlassian.net", err=True)
|
|
44
|
+
click.echo(" JIRA_EMAIL=user@example.com", err=True)
|
|
45
|
+
click.echo(" JIRA_API_TOKEN=your_api_token", err=True)
|
|
46
|
+
raise SystemExit(1)
|
|
47
|
+
|
|
48
|
+
return JiraClient(base_url=base_url, email=email, api_token=api_token)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resolve_project(project: Optional[str]) -> str:
|
|
52
|
+
"""Resolve project key from option or env (JIRA_PROJECT/JIRA_PROJECT_KEY)."""
|
|
53
|
+
resolved = project or os.environ.get("JIRA_PROJECT") or os.environ.get("JIRA_PROJECT_KEY")
|
|
54
|
+
if not resolved:
|
|
55
|
+
click.echo("❌ Missing project key. Use --project or set JIRA_PROJECT in local.env/.env", err=True)
|
|
56
|
+
raise SystemExit(1)
|
|
57
|
+
return resolved
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@cli.command(name="list")
|
|
61
|
+
@click.option("--project", "-p", default="", help="Jira project key (e.g. PROJ); defaults to JIRA_PROJECT/JIRA_PROJECT_KEY")
|
|
62
|
+
@click.option("--status", "-s", default="", help="Filter by status (e.g. 'To Do')")
|
|
63
|
+
@click.option("--assignee", "-a", default="", help="Filter by assignee")
|
|
64
|
+
@click.option("--label", "-l", default="", help="Filter by label")
|
|
65
|
+
@click.option("--jql", default="", help="Additional JQL conditions (AND appended)")
|
|
66
|
+
@click.option("--max-results", type=int, default=50, show_default=True, help="Max issues to return")
|
|
67
|
+
@click.option("--format", type=click.Choice(["table", "json", "csv", "md"]), default="table", help="Output format")
|
|
68
|
+
def list_issues(project: str, status: str, assignee: str, label: str, jql: str, max_results: int, format: str):
|
|
69
|
+
"""List issues in a project with optional filters."""
|
|
70
|
+
try:
|
|
71
|
+
client = _get_jira_client()
|
|
72
|
+
query = JiraQuery(client)
|
|
73
|
+
renderer = JiraRenderer()
|
|
74
|
+
project_key = _resolve_project(project)
|
|
75
|
+
|
|
76
|
+
rows = query.search_project(
|
|
77
|
+
project_key=project_key,
|
|
78
|
+
status=status or None,
|
|
79
|
+
assignee=assignee or None,
|
|
80
|
+
label=label or None,
|
|
81
|
+
jql_extra=jql or None,
|
|
82
|
+
max_results=max_results,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if format == "table":
|
|
86
|
+
renderer.table(rows, title=f"Issues in {project_key}")
|
|
87
|
+
elif format == "json":
|
|
88
|
+
output = renderer.json(rows)
|
|
89
|
+
renderer.print(output)
|
|
90
|
+
elif format == "csv":
|
|
91
|
+
output = renderer.csv(rows)
|
|
92
|
+
renderer.print(output)
|
|
93
|
+
elif format == "md":
|
|
94
|
+
output = renderer.markdown(rows)
|
|
95
|
+
renderer.print(output)
|
|
96
|
+
|
|
97
|
+
except SystemExit:
|
|
98
|
+
raise
|
|
99
|
+
except Exception as e:
|
|
100
|
+
click.echo(f"❌ Error: {e}", err=True)
|
|
101
|
+
raise SystemExit(1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@cli.command(name="search")
|
|
105
|
+
@click.argument("jql")
|
|
106
|
+
@click.option("--max-results", type=int, default=50, show_default=True)
|
|
107
|
+
@click.option("--format", type=click.Choice(["table", "json", "csv", "md"]), default="table")
|
|
108
|
+
def search(jql: str, max_results: int, format: str):
|
|
109
|
+
"""Search issues using custom JQL."""
|
|
110
|
+
try:
|
|
111
|
+
client = _get_jira_client()
|
|
112
|
+
query = JiraQuery(client)
|
|
113
|
+
renderer = JiraRenderer()
|
|
114
|
+
|
|
115
|
+
rows = query.search_custom_jql(jql, max_results=max_results)
|
|
116
|
+
|
|
117
|
+
if format == "table":
|
|
118
|
+
renderer.table(rows, title="Search Results")
|
|
119
|
+
elif format == "json":
|
|
120
|
+
output = renderer.json(rows)
|
|
121
|
+
renderer.print(output)
|
|
122
|
+
elif format == "csv":
|
|
123
|
+
output = renderer.csv(rows)
|
|
124
|
+
renderer.print(output)
|
|
125
|
+
elif format == "md":
|
|
126
|
+
output = renderer.markdown(rows)
|
|
127
|
+
renderer.print(output)
|
|
128
|
+
|
|
129
|
+
except SystemExit:
|
|
130
|
+
raise
|
|
131
|
+
except Exception as e:
|
|
132
|
+
click.echo(f"❌ Error: {e}", err=True)
|
|
133
|
+
raise SystemExit(1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@cli.command(name="find")
|
|
137
|
+
@click.option("--project", "-p", default="", help="Jira project key; defaults to JIRA_PROJECT/JIRA_PROJECT_KEY")
|
|
138
|
+
@click.argument("text")
|
|
139
|
+
@click.option("--max-results", type=int, default=50, show_default=True)
|
|
140
|
+
@click.option("--format", type=click.Choice(["table", "json", "csv"]), default="table")
|
|
141
|
+
def find(project: str, text: str, max_results: int, format: str):
|
|
142
|
+
"""Find issues by text in summary/description."""
|
|
143
|
+
try:
|
|
144
|
+
client = _get_jira_client()
|
|
145
|
+
query = JiraQuery(client)
|
|
146
|
+
renderer = JiraRenderer()
|
|
147
|
+
project_key = _resolve_project(project)
|
|
148
|
+
|
|
149
|
+
rows = query.find_by_text(project_key, text, max_results=max_results)
|
|
150
|
+
|
|
151
|
+
if format == "table":
|
|
152
|
+
renderer.table(rows, title=f"Search for '{text}' in {project_key}")
|
|
153
|
+
elif format == "json":
|
|
154
|
+
output = renderer.json(rows)
|
|
155
|
+
renderer.print(output)
|
|
156
|
+
elif format == "csv":
|
|
157
|
+
output = renderer.csv(rows)
|
|
158
|
+
renderer.print(output)
|
|
159
|
+
|
|
160
|
+
except SystemExit:
|
|
161
|
+
raise
|
|
162
|
+
except Exception as e:
|
|
163
|
+
click.echo(f"❌ Error: {e}", err=True)
|
|
164
|
+
raise SystemExit(1)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@cli.command(name="view")
|
|
168
|
+
@click.argument("key")
|
|
169
|
+
@click.option("--comments", is_flag=True, help="Include issue comments")
|
|
170
|
+
def view(key: str, comments: bool):
|
|
171
|
+
"""View issue details."""
|
|
172
|
+
try:
|
|
173
|
+
client = _get_jira_client()
|
|
174
|
+
issue = client.get_issue(
|
|
175
|
+
key,
|
|
176
|
+
fields=["key", "summary", "description", "status", "priority", "assignee", "labels", "comment"],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
fields = issue.get("fields", {})
|
|
180
|
+
|
|
181
|
+
click.echo(f"\n📌 {issue['key']}: {fields.get('summary', 'N/A')}\n")
|
|
182
|
+
click.echo(f" Status: {fields.get('status', {}).get('name', 'N/A')}")
|
|
183
|
+
click.echo(f" Priority: {fields.get('priority', {}).get('name', 'N/A')}")
|
|
184
|
+
assignee = fields.get("assignee")
|
|
185
|
+
click.echo(f" Assignee: {assignee.get('displayName', 'Unassigned') if assignee else 'Unassigned'}")
|
|
186
|
+
labels = fields.get("labels", [])
|
|
187
|
+
click.echo(f" Labels: {', '.join(labels) if labels else 'None'}")
|
|
188
|
+
|
|
189
|
+
description = fields.get("description")
|
|
190
|
+
if description:
|
|
191
|
+
click.echo(f"\n📝 Description:")
|
|
192
|
+
if isinstance(description, dict): # Rich text format
|
|
193
|
+
click.echo(" (Rich text format)")
|
|
194
|
+
else:
|
|
195
|
+
click.echo(f" {description}")
|
|
196
|
+
|
|
197
|
+
if comments:
|
|
198
|
+
comments_list = fields.get("comment", {}).get("comments", [])
|
|
199
|
+
if comments_list:
|
|
200
|
+
click.echo(f"\n💬 Comments ({len(comments_list)}):")
|
|
201
|
+
for c in comments_list:
|
|
202
|
+
author = c.get("author", {}).get("displayName", "Unknown")
|
|
203
|
+
body = c.get("body", {})
|
|
204
|
+
if isinstance(body, dict):
|
|
205
|
+
click.echo(f" @{author}: (Rich text)")
|
|
206
|
+
else:
|
|
207
|
+
click.echo(f" @{author}: {body}")
|
|
208
|
+
else:
|
|
209
|
+
click.echo("\n💬 No comments")
|
|
210
|
+
|
|
211
|
+
click.echo()
|
|
212
|
+
|
|
213
|
+
except SystemExit:
|
|
214
|
+
raise
|
|
215
|
+
except Exception as e:
|
|
216
|
+
click.echo(f"❌ Error: {e}", err=True)
|
|
217
|
+
raise SystemExit(1)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@cli.command(name="assign")
|
|
221
|
+
@click.argument("key")
|
|
222
|
+
@click.argument("assignee")
|
|
223
|
+
@click.option("--comment", default="", help="Optional transition comment")
|
|
224
|
+
def assign_issue(key: str, assignee: str, comment: str):
|
|
225
|
+
"""Assign issue to user."""
|
|
226
|
+
try:
|
|
227
|
+
client = _get_jira_client()
|
|
228
|
+
client.assign_issue(key, assignee)
|
|
229
|
+
click.echo(f"✅ Assigned {key} to {assignee}")
|
|
230
|
+
|
|
231
|
+
except SystemExit:
|
|
232
|
+
raise
|
|
233
|
+
except Exception as e:
|
|
234
|
+
click.echo(f"❌ Error: {e}", err=True)
|
|
235
|
+
raise SystemExit(1)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@cli.command(name="transition")
|
|
239
|
+
@click.argument("key")
|
|
240
|
+
@click.argument("transition_id")
|
|
241
|
+
@click.option("--comment", default="", help="Optional transition comment")
|
|
242
|
+
def transition(key: str, transition_id: str, comment: str):
|
|
243
|
+
"""Transition issue to new status."""
|
|
244
|
+
try:
|
|
245
|
+
client = _get_jira_client()
|
|
246
|
+
client.transition_issue(key, transition_id, comment=comment or None)
|
|
247
|
+
click.echo(f"✅ Transitioned {key} to {transition_id}")
|
|
248
|
+
|
|
249
|
+
except SystemExit:
|
|
250
|
+
raise
|
|
251
|
+
except Exception as e:
|
|
252
|
+
click.echo(f"❌ Error: {e}", err=True)
|
|
253
|
+
raise SystemExit(1)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main():
|
|
257
|
+
"""Main entry point."""
|
|
258
|
+
cli()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
main()
|
jira_cli/client.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Jira API client."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from jira import JIRA
|
|
6
|
+
|
|
7
|
+
from .models import JiraSearchResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JiraClient:
|
|
11
|
+
"""Client wrapper around the official jira Python library."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, base_url: str, email: str, api_token: str, dry_run: bool = False, timeout: int = 30):
|
|
14
|
+
"""
|
|
15
|
+
Initialize Jira client.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
base_url: Jira instance URL (e.g. https://company.atlassian.net)
|
|
19
|
+
email: Jira user email
|
|
20
|
+
api_token: Jira API token (from Account Settings → Security)
|
|
21
|
+
dry_run: If True, only print requests without executing
|
|
22
|
+
timeout: Request timeout in seconds
|
|
23
|
+
"""
|
|
24
|
+
self.base_url = base_url.rstrip("/")
|
|
25
|
+
self.email = email
|
|
26
|
+
self.api_token = api_token
|
|
27
|
+
self.dry_run = dry_run
|
|
28
|
+
self.timeout = timeout
|
|
29
|
+
self._jira = JIRA(
|
|
30
|
+
server=self.base_url,
|
|
31
|
+
basic_auth=(self.email, self.api_token),
|
|
32
|
+
options={"rest_api_version": "3"},
|
|
33
|
+
timeout=self.timeout,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def search(
|
|
37
|
+
self,
|
|
38
|
+
jql: str,
|
|
39
|
+
fields: Optional[list[str]] = None,
|
|
40
|
+
start_at: int = 0,
|
|
41
|
+
max_results: int = 50,
|
|
42
|
+
expand: Optional[list[str]] = None,
|
|
43
|
+
) -> JiraSearchResult:
|
|
44
|
+
"""
|
|
45
|
+
Search issues using JQL via official client.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
jql: JQL query string
|
|
49
|
+
fields: List of fields to return (e.g. ['key', 'summary', 'status'])
|
|
50
|
+
start_at: Start index for pagination
|
|
51
|
+
max_results: Max issues to return (max 100 for API limit)
|
|
52
|
+
expand: List of fields to expand (e.g. 'changelog')
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
JiraSearchResult with issues list
|
|
56
|
+
"""
|
|
57
|
+
if self.dry_run:
|
|
58
|
+
print(f"[dry-run] POST /search/jql | JQL={jql} | start_at={start_at} | max_results={max_results}")
|
|
59
|
+
return JiraSearchResult(issues=[], total=0)
|
|
60
|
+
|
|
61
|
+
raw = self._jira.search_issues(
|
|
62
|
+
jql_str=jql,
|
|
63
|
+
startAt=start_at,
|
|
64
|
+
maxResults=max_results,
|
|
65
|
+
fields=fields if fields else None,
|
|
66
|
+
expand=",".join(expand) if expand else None,
|
|
67
|
+
json_result=True,
|
|
68
|
+
use_post=True,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return JiraSearchResult(**raw)
|
|
72
|
+
|
|
73
|
+
def get_issue(self, key: str, fields: Optional[list[str]] = None, expand: Optional[list[str]] = None) -> dict:
|
|
74
|
+
"""
|
|
75
|
+
Fetch single issue by key.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
key: Issue key (e.g. 'JIRA-123')
|
|
79
|
+
fields: List of fields to return
|
|
80
|
+
expand: List of fields to expand
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Issue dict
|
|
84
|
+
"""
|
|
85
|
+
if self.dry_run:
|
|
86
|
+
print(f"[dry-run] GET /issues/{key}")
|
|
87
|
+
return {}
|
|
88
|
+
|
|
89
|
+
issue = self._jira.issue(
|
|
90
|
+
key,
|
|
91
|
+
fields=fields if fields else None,
|
|
92
|
+
expand=",".join(expand) if expand else None,
|
|
93
|
+
)
|
|
94
|
+
return issue.raw
|
|
95
|
+
|
|
96
|
+
def get_issue_comments(self, key: str, expand_changelog: bool = False) -> list[dict]:
|
|
97
|
+
"""
|
|
98
|
+
Fetch comments for an issue.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
key: Issue key
|
|
102
|
+
expand_changelog: Include changelog details
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
List of comment dicts
|
|
106
|
+
"""
|
|
107
|
+
if self.dry_run:
|
|
108
|
+
print(f"[dry-run] GET /issues/{key}/comments")
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
comments = self._jira.comments(key)
|
|
112
|
+
return [c.raw for c in comments]
|
|
113
|
+
|
|
114
|
+
def transition_issue(self, key: str, transition_id: str, comment: Optional[str] = None) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Transition issue to new status.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
key: Issue key
|
|
120
|
+
transition_id: Transition ID (e.g. 'In Progress', 'Done')
|
|
121
|
+
comment: Optional comment to add
|
|
122
|
+
"""
|
|
123
|
+
if self.dry_run:
|
|
124
|
+
print(f"[dry-run] POST /issues/{key}/transitions | transition={transition_id}")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
self._jira.transition_issue(key, transition_id, comment=comment or None)
|
|
128
|
+
|
|
129
|
+
def assign_issue(self, key: str, assignee_key: str) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Assign issue to user.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
key: Issue key
|
|
135
|
+
assignee_key: User key or email
|
|
136
|
+
"""
|
|
137
|
+
if self.dry_run:
|
|
138
|
+
print(f"[dry-run] PUT /issues/{key} | assignee={assignee_key}")
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
self._jira.assign_issue(key, assignee_key)
|
jira_cli/dotenv.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""DotEnv handling — robust loading from CWD/.env or local.env."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DotEnv:
|
|
8
|
+
"""Load environment variables from .env or local.env in CWD or parent directories."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, search_up: int = 3, verbose: bool = False):
|
|
11
|
+
"""
|
|
12
|
+
Args:
|
|
13
|
+
search_up: How many parent directories to search up for .env/local.env
|
|
14
|
+
verbose: Print debug info when loading env files
|
|
15
|
+
"""
|
|
16
|
+
self.search_up = search_up
|
|
17
|
+
self.verbose = verbose
|
|
18
|
+
self._loaded_from = None
|
|
19
|
+
|
|
20
|
+
def load(self) -> dict[str, str]:
|
|
21
|
+
"""
|
|
22
|
+
Load from .env or local.env in CWD or parent directories.
|
|
23
|
+
Tries: CWD/local.env → CWD/.env → parent/local.env → parent/.env (up to search_up levels)
|
|
24
|
+
|
|
25
|
+
Returns dict of loaded variables (also sets in os.environ).
|
|
26
|
+
"""
|
|
27
|
+
cwd = Path.cwd()
|
|
28
|
+
candidates = []
|
|
29
|
+
|
|
30
|
+
# Build candidate list: current and parent dirs, prioritizing local.env
|
|
31
|
+
for level in range(self.search_up + 1):
|
|
32
|
+
target_dir = cwd
|
|
33
|
+
for _ in range(level):
|
|
34
|
+
target_dir = target_dir.parent
|
|
35
|
+
if target_dir == target_dir.parent: # Reached filesystem root
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
# local.env first, then .env
|
|
39
|
+
candidates.append(target_dir / "local.env")
|
|
40
|
+
candidates.append(target_dir / ".env")
|
|
41
|
+
|
|
42
|
+
loaded = {}
|
|
43
|
+
for candidate in candidates:
|
|
44
|
+
if candidate.exists() and candidate.is_file():
|
|
45
|
+
self._load_file(candidate)
|
|
46
|
+
with open(candidate, "r", encoding="utf-8") as f:
|
|
47
|
+
for line in f:
|
|
48
|
+
line = line.strip()
|
|
49
|
+
if not line or line.startswith("#"):
|
|
50
|
+
continue
|
|
51
|
+
if "=" in line:
|
|
52
|
+
key, val = line.split("=", 1)
|
|
53
|
+
key = key.strip()
|
|
54
|
+
val = val.strip()
|
|
55
|
+
loaded[key] = val
|
|
56
|
+
os.environ[key] = val
|
|
57
|
+
|
|
58
|
+
self._loaded_from = candidate
|
|
59
|
+
if self.verbose:
|
|
60
|
+
print(f"[dotenv] Loaded from {self._loaded_from}")
|
|
61
|
+
return loaded
|
|
62
|
+
|
|
63
|
+
if self.verbose:
|
|
64
|
+
print("[dotenv] No .env/local.env found")
|
|
65
|
+
|
|
66
|
+
return loaded
|
|
67
|
+
|
|
68
|
+
def _load_file(self, path: Path) -> None:
|
|
69
|
+
"""Load single env file into os.environ."""
|
|
70
|
+
if self.verbose:
|
|
71
|
+
print(f"[dotenv] Reading {path}")
|
|
72
|
+
|
|
73
|
+
def get(self, key: str, default: str = "") -> str:
|
|
74
|
+
"""Get env variable (from loaded or os.environ)."""
|
|
75
|
+
return os.environ.get(key, default)
|
|
76
|
+
|
|
77
|
+
def require(self, *keys: str) -> dict[str, str]:
|
|
78
|
+
"""Get multiple required env variables. Raise if any missing."""
|
|
79
|
+
missing = [k for k in keys if not os.environ.get(k)]
|
|
80
|
+
if missing:
|
|
81
|
+
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
|
|
82
|
+
|
|
83
|
+
return {k: os.environ[k] for k in keys}
|
jira_cli/models.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Pydantic models for Jira API responses."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JiraUser(BaseModel):
|
|
10
|
+
"""Jira user representation."""
|
|
11
|
+
|
|
12
|
+
key: str
|
|
13
|
+
display_name: str = Field(alias="displayName")
|
|
14
|
+
email: Optional[str] = None
|
|
15
|
+
avatar_url: Optional[str] = Field(None, alias="avatarUrls")
|
|
16
|
+
|
|
17
|
+
class Config:
|
|
18
|
+
populate_by_name = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JiraIssueField(BaseModel):
|
|
22
|
+
"""Jira issue field (stripped down)."""
|
|
23
|
+
|
|
24
|
+
summary: str
|
|
25
|
+
status: Optional[dict | str] = None
|
|
26
|
+
priority: Optional[dict | str] = None
|
|
27
|
+
assignee: Optional[dict] = None
|
|
28
|
+
reporter: Optional[dict] = None
|
|
29
|
+
labels: list[str] = []
|
|
30
|
+
created: Optional[str] = None
|
|
31
|
+
updated: Optional[str] = None
|
|
32
|
+
description: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
class Config:
|
|
35
|
+
extra = "allow"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class JiraIssue(BaseModel):
|
|
39
|
+
"""Complete Jira issue."""
|
|
40
|
+
|
|
41
|
+
key: str
|
|
42
|
+
fields: JiraIssueField
|
|
43
|
+
|
|
44
|
+
class Config:
|
|
45
|
+
extra = "allow"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class JiraSearchResult(BaseModel):
|
|
49
|
+
"""Response from Jira search endpoint."""
|
|
50
|
+
|
|
51
|
+
issues: list[JiraIssue] = []
|
|
52
|
+
total: int = 0
|
|
53
|
+
max_results: int = Field(0, alias="maxResults")
|
|
54
|
+
start_at: int = Field(0, alias="startAt")
|
|
55
|
+
next_page_token: Optional[str] = Field(None, alias="nextPageToken")
|
|
56
|
+
|
|
57
|
+
class Config:
|
|
58
|
+
populate_by_name = True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class IssueRow(BaseModel):
|
|
62
|
+
"""Flattened issue for table/csv output."""
|
|
63
|
+
|
|
64
|
+
key: str
|
|
65
|
+
summary: str
|
|
66
|
+
status: str = ""
|
|
67
|
+
priority: str = ""
|
|
68
|
+
assignee: str = ""
|
|
69
|
+
updated: str = ""
|
|
70
|
+
labels: str = ""
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def from_jira_issue(issue: JiraIssue) -> "IssueRow":
|
|
74
|
+
"""Convert Jira issue to flattened row."""
|
|
75
|
+
fields = issue.fields
|
|
76
|
+
|
|
77
|
+
status = ""
|
|
78
|
+
if isinstance(fields.status, dict):
|
|
79
|
+
status = fields.status.get("name", "")
|
|
80
|
+
elif isinstance(fields.status, str):
|
|
81
|
+
status = fields.status
|
|
82
|
+
|
|
83
|
+
priority = ""
|
|
84
|
+
if isinstance(fields.priority, dict):
|
|
85
|
+
priority = fields.priority.get("name", "")
|
|
86
|
+
elif isinstance(fields.priority, str):
|
|
87
|
+
priority = fields.priority
|
|
88
|
+
|
|
89
|
+
assignee = ""
|
|
90
|
+
if fields.assignee:
|
|
91
|
+
assignee = (
|
|
92
|
+
fields.assignee.get("displayName")
|
|
93
|
+
or fields.assignee.get("name")
|
|
94
|
+
or fields.assignee.get("accountId")
|
|
95
|
+
or fields.assignee.get("key", "")
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
labels = ", ".join(fields.labels) if fields.labels else ""
|
|
99
|
+
|
|
100
|
+
return IssueRow(
|
|
101
|
+
key=issue.key,
|
|
102
|
+
summary=fields.summary,
|
|
103
|
+
status=status,
|
|
104
|
+
priority=priority,
|
|
105
|
+
assignee=assignee,
|
|
106
|
+
updated=fields.updated or "",
|
|
107
|
+
labels=labels,
|
|
108
|
+
)
|
jira_cli/query.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""JQL query builder and search logic."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .client import JiraClient
|
|
6
|
+
from .models import IssueRow
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class JiraQuery:
|
|
10
|
+
"""Build and execute JQL queries."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, client: JiraClient):
|
|
13
|
+
self.client = client
|
|
14
|
+
|
|
15
|
+
def search_project(
|
|
16
|
+
self,
|
|
17
|
+
project_key: str,
|
|
18
|
+
status: Optional[str] = None,
|
|
19
|
+
assignee: Optional[str] = None,
|
|
20
|
+
label: Optional[str] = None,
|
|
21
|
+
jql_extra: Optional[str] = None,
|
|
22
|
+
fields: Optional[list[str]] = None,
|
|
23
|
+
max_results: int = 50,
|
|
24
|
+
) -> list[IssueRow]:
|
|
25
|
+
"""
|
|
26
|
+
Search issues in a project with optional filters.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
project_key: Jira project key
|
|
30
|
+
status: Filter by status (e.g. 'To Do', 'In Progress')
|
|
31
|
+
assignee: Filter by assignee
|
|
32
|
+
label: Filter by label
|
|
33
|
+
jql_extra: Additional JQL conditions (AND appended)
|
|
34
|
+
fields: Specific fields to fetch
|
|
35
|
+
max_results: Max results to return
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
List of IssueRow (flattened for output)
|
|
39
|
+
"""
|
|
40
|
+
conditions = [f"project = {project_key}"]
|
|
41
|
+
|
|
42
|
+
if status:
|
|
43
|
+
conditions.append(f"status = '{status}'")
|
|
44
|
+
if assignee:
|
|
45
|
+
conditions.append(f"assignee = '{assignee}'")
|
|
46
|
+
if label:
|
|
47
|
+
conditions.append(f"labels = {label}")
|
|
48
|
+
if jql_extra:
|
|
49
|
+
conditions.append(jql_extra)
|
|
50
|
+
|
|
51
|
+
jql = " AND ".join(conditions)
|
|
52
|
+
|
|
53
|
+
# Default fields to fetch
|
|
54
|
+
if not fields:
|
|
55
|
+
fields = ["key", "summary", "status", "priority", "assignee", "updated", "labels"]
|
|
56
|
+
|
|
57
|
+
result = self.client.search(jql, fields=fields, max_results=max_results)
|
|
58
|
+
|
|
59
|
+
return [IssueRow.from_jira_issue(issue) for issue in result.issues]
|
|
60
|
+
|
|
61
|
+
def search_custom_jql(self, jql: str, fields: Optional[list[str]] = None, max_results: int = 50) -> list[IssueRow]:
|
|
62
|
+
"""
|
|
63
|
+
Execute custom JQL query.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
jql: Full JQL query string
|
|
67
|
+
fields: Specific fields to fetch
|
|
68
|
+
max_results: Max results
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
List of IssueRow
|
|
72
|
+
"""
|
|
73
|
+
if not fields:
|
|
74
|
+
fields = ["key", "summary", "status", "priority", "assignee", "updated", "labels"]
|
|
75
|
+
|
|
76
|
+
result = self.client.search(jql, fields=fields, max_results=max_results)
|
|
77
|
+
return [IssueRow.from_jira_issue(issue) for issue in result.issues]
|
|
78
|
+
|
|
79
|
+
def find_by_text(
|
|
80
|
+
self, project_key: str, text: str, fields: Optional[list[str]] = None, max_results: int = 50
|
|
81
|
+
) -> list[IssueRow]:
|
|
82
|
+
"""
|
|
83
|
+
Search issues by summary/description text.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
project_key: Project key
|
|
87
|
+
text: Search text
|
|
88
|
+
fields: Specific fields to fetch
|
|
89
|
+
max_results: Max results
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List of IssueRow
|
|
93
|
+
"""
|
|
94
|
+
jql = f'project = {project_key} AND (summary ~ "{text}" OR description ~ "{text}")'
|
|
95
|
+
return self.search_custom_jql(jql, fields=fields, max_results=max_results)
|
jira_cli/render.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Output rendering (table, JSON, CSV, Markdown)."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from .models import IssueRow
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JiraRenderer:
|
|
15
|
+
"""Render Jira data in multiple formats."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, console: Optional[Console] = None):
|
|
18
|
+
self.console = console or Console()
|
|
19
|
+
|
|
20
|
+
def table(self, rows: list[IssueRow], title: Optional[str] = None) -> None:
|
|
21
|
+
"""Render as rich table."""
|
|
22
|
+
if not rows:
|
|
23
|
+
self.console.print("[yellow]No issues found.[/yellow]")
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
t = Table(title=title)
|
|
27
|
+
t.add_column("Key", style="cyan")
|
|
28
|
+
t.add_column("Summary", style="white")
|
|
29
|
+
t.add_column("Status", style="green")
|
|
30
|
+
t.add_column("Priority", style="yellow")
|
|
31
|
+
t.add_column("Assignee")
|
|
32
|
+
t.add_column("Updated", style="dim")
|
|
33
|
+
|
|
34
|
+
for row in rows:
|
|
35
|
+
t.add_row(
|
|
36
|
+
row.key,
|
|
37
|
+
row.summary[:70] + "..." if len(row.summary) > 70 else row.summary,
|
|
38
|
+
row.status,
|
|
39
|
+
row.priority,
|
|
40
|
+
row.assignee,
|
|
41
|
+
row.updated[:10] if row.updated else "",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
self.console.print(t)
|
|
45
|
+
self.console.print(f"[green]✓ {len(rows)} issue(s)[/green]")
|
|
46
|
+
|
|
47
|
+
def json(self, rows: list[IssueRow]) -> str:
|
|
48
|
+
"""Render as JSON."""
|
|
49
|
+
data = [row.model_dump() for row in rows]
|
|
50
|
+
return json.dumps(data, indent=2)
|
|
51
|
+
|
|
52
|
+
def csv(self, rows: list[IssueRow]) -> str:
|
|
53
|
+
"""Render as CSV."""
|
|
54
|
+
if not rows:
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
output = io.StringIO()
|
|
58
|
+
writer = csv.DictWriter(output, fieldnames=rows[0].model_fields.keys())
|
|
59
|
+
writer.writeheader()
|
|
60
|
+
for row in rows:
|
|
61
|
+
writer.writerow(row.model_dump())
|
|
62
|
+
|
|
63
|
+
return output.getvalue()
|
|
64
|
+
|
|
65
|
+
def markdown(self, rows: list[IssueRow]) -> str:
|
|
66
|
+
"""Render as Markdown table."""
|
|
67
|
+
if not rows:
|
|
68
|
+
return "No issues found.\n"
|
|
69
|
+
|
|
70
|
+
lines = [
|
|
71
|
+
"| Key | Summary | Status | Priority | Assignee |",
|
|
72
|
+
"|-----|---------|--------|----------|----------|",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
for row in rows:
|
|
76
|
+
summary = row.summary.replace("|", "\\|")[:70]
|
|
77
|
+
lines.append(f"| {row.key} | {summary} | {row.status} | {row.priority} | {row.assignee} |")
|
|
78
|
+
|
|
79
|
+
return "\n".join(lines) + "\n"
|
|
80
|
+
|
|
81
|
+
def print(self, output: str) -> None:
|
|
82
|
+
"""Print raw output (for JSON/CSV/Markdown)."""
|
|
83
|
+
self.console.print(output, highlight=False)
|