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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jira-cli = jira_cli.cli:main
@@ -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
@@ -0,0 +1,4 @@
1
+ """Jira CLI — modular command-line interface for Jira issue management."""
2
+
3
+ __version__ = "0.1.2"
4
+ __author__ = "Colenio"
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)