66from pathlib import Path
77import json
88import tomllib
9+ from collections import defaultdict
910
10- from jira import JIRA
11+ from jira import JIRA , Issue
1112from jira .utils import remove_empty_attributes
1213from pydantic .dataclasses import dataclass
1314import click
@@ -26,22 +27,12 @@ def read_config() -> Config:
2627 return Config (** data )
2728
2829
29- def search_issues_paginated (jira : JIRA , jql : str ):
30- start_at = 0
31- max_results = 50
32- while results := jira .search_issues (
33- jql ,
34- maxResults = max_results ,
35- startAt = start_at ,
36- ):
37- yield from results
38- start_at += max_results
39-
40-
4130class JiraContext :
4231 def __init__ (self , config : Config ):
4332 self ._config = config
4433 self ._jira : JIRA | None = None
34+ self ._jira_fields : list [dict [str , t .Any ]] | None = None
35+ self ._sp_field_id : str | None = None
4536 self .project : str = config .project
4637
4738 @property
@@ -50,6 +41,77 @@ def jira(self) -> JIRA:
5041 self ._jira = JIRA (server = self ._config .server , token_auth = self ._config .token )
5142 return self ._jira
5243
44+ @property
45+ def jira_fields (self ) -> list [dict [str , t .Any ]]:
46+ if self ._jira_fields is None :
47+ self ._jira_fields = self .jira .fields ()
48+ return self ._jira_fields
49+
50+ @property
51+ def sp_field_id (self ) -> str :
52+ if self ._sp_field_id is None :
53+ self ._sp_field_id = next (
54+ (
55+ field ["id" ]
56+ for field in self .jira_fields
57+ if field ["name" ] == "Story Points"
58+ )
59+ )
60+ return self ._sp_field_id
61+
62+ def search_issues_paginated (
63+ self , jql : str , max_results : int | None = None
64+ ) -> t .Iterator [Issue ]:
65+ start_at = 0
66+ max_results = 50 # TODO
67+ while results := self .jira .search_issues (
68+ jql ,
69+ maxResults = max_results ,
70+ startAt = start_at ,
71+ ):
72+ yield from results
73+ start_at += max_results
74+
75+ def print_issue (self , issue ) -> None :
76+ print (
77+ issue .fields .issuetype .name ,
78+ "\t " ,
79+ issue ,
80+ "\t " ,
81+ issue .get_field (self .sp_field_id ),
82+ "\t " ,
83+ issue .fields .summary ,
84+ )
85+
86+ def print_issue_detail (self , issue ) -> None :
87+ print (issue .fields .issuetype .name , issue )
88+ print (issue .fields .summary )
89+ print (issue .fields .description )
90+ print ("Status:" , issue .fields .status .name )
91+ for fieldname in ["Story Points" , "Resolution" ]:
92+ field_id = next (
93+ (
94+ field ["id" ]
95+ for field in self .jira_fields
96+ if field ["name" ] == fieldname
97+ )
98+ )
99+ print (fieldname + ":" , issue .get_field (field_id ))
100+
101+ def print_kanban (self , issues ) -> None :
102+ results : dict [str , list [Issue ]] = defaultdict (list )
103+ sp_accumulator : dict [str , int ] = defaultdict (int )
104+ for issue in issues :
105+ results [issue .fields .status .name ].append (issue )
106+ sp_accumulator [issue .fields .status .name ] += issue .get_field (
107+ self .sp_field_id
108+ )
109+ for status in ["New" , "In Progress" , "Closed" ]:
110+ issues = results [status ]
111+ print (f"## { status } ({ sp_accumulator [status ]} )" )
112+ for issue in issues :
113+ self .print_issue (issue )
114+
53115
54116pass_jira_context = click .make_pass_decorator (JiraContext )
55117
@@ -62,7 +124,7 @@ def main(ctx: click.Context, /) -> None:
62124
63125
64126@main .command ()
65- @click .option ("--my/--all " , default = None )
127+ @click .option ("--my/--unassigned " , default = None , help = "defaults to all" )
66128@pass_jira_context
67129def sprint (ctx : JiraContext , / , my : bool ) -> None :
68130 conditions = [
@@ -71,44 +133,37 @@ def sprint(ctx: JiraContext, /, my: bool) -> None:
71133 ]
72134 if my is True :
73135 conditions .append ("assignee = currentUser()" )
74- elif my is None :
136+ elif my is False :
75137 conditions .append ("assignee is EMPTY" )
76- # False -> all sprint items
138+ # None -> all sprint items
77139
78- for issue in search_issues_paginated (
79- ctx .jira ,
80- " AND " .join (conditions ) + " ORDER BY priority DESC, updated DESC" ,
81- ):
82- print (issue , issue .fields .summary )
140+ jql = " AND " .join (conditions ) + " ORDER BY priority DESC, updated DESC"
141+
142+ ctx .print_kanban (ctx .search_issues_paginated (jql ))
83143
84144
85145@main .command ()
86146@pass_jira_context
87147def issues (ctx : JiraContext , / ) -> None :
88- for issue in search_issues_paginated (
89- ctx .jira ,
90- f"project = { ctx .project } AND resolution = Unresolved ORDER BY priority DESC, updated DESC" ,
91- ):
92- print (issue , issue .fields .summary )
148+ jql = f"project = { ctx .project } AND resolution = Unresolved ORDER BY priority DESC, updated DESC"
149+ for issue in ctx .search_issues_paginated (jql ):
150+ ctx .print_issue (issue )
93151
94152
95153@main .command ()
96154@pass_jira_context
97155def blocker (ctx : JiraContext , / ) -> None :
98- for issue in ctx .jira .search_issues (
99- f"project = { ctx .project } AND resolution = Unresolved AND priority = blocker ORDER BY updated DESC"
100- ):
101- print (issue , issue .fields .summary )
156+ jql = f"project = { ctx .project } AND resolution = Unresolved AND priority = blocker ORDER BY updated DESC"
157+ for issue in ctx .search_issues_paginated (jql ):
158+ ctx .print_issue (issue )
102159
103160
104161@main .command ()
105162@pass_jira_context
106163def my_issues (ctx : JiraContext , / ) -> None :
107- jira = ctx .jira
108- for issue in jira .search_issues (
109- "assignee = currentUser() AND resolution = Unresolved order by updated DESC"
110- ):
111- print (issue , issue .fields .summary )
164+ jql = "assignee = currentUser() AND resolution = Unresolved order by updated DESC"
165+ for issue in ctx .search_issues_paginated (jql ):
166+ ctx .print_issue (issue )
112167
113168
114169@main .command ()
@@ -125,8 +180,8 @@ def search(
125180 search_phrase : str ,
126181) -> None :
127182 jql = f"project = { ctx .project } AND resolution = Unresolved AND text ~ '{ search_phrase } ' ORDER BY updated DESC"
128- for issue in ctx .jira . search_issues (jql ):
129- print (issue , issue . fields . summary )
183+ for issue in ctx .search_issues_paginated (jql ):
184+ ctx . print_issue (issue )
130185
131186
132187@main .command ()
@@ -146,20 +201,12 @@ def show(
146201 raw_issue = remove_empty_attributes (raw_issue )
147202 print (json .dumps (raw_issue ))
148203 else :
149- print (issue .fields .issuetype .name , issue )
150- print (issue .fields .summary )
151- print (issue .fields .description )
152- print ("Status:" , issue .fields .status .name )
153- fields = ctx .jira .fields ()
154- for fieldname in ["Story Points" , "Resolution" ]:
155- field_id = next (
156- (field ["id" ] for field in fields if field ["name" ] == fieldname )
157- )
158- print (fieldname + ":" , issue .get_field (field_id ))
204+ ctx .print_issue_detail (issue )
159205
160206
161207@main .command ()
162- @click .option ("--assign/--no-assign" , default = True )
208+ @click .option ("--assign/--no-assign" , default = False )
209+ # @click.option("--task/--bug/--epic")
163210@click .argument ("summary" )
164211@click .argument ("description" )
165212@pass_jira_context
@@ -179,7 +226,7 @@ def create(
179226 if assign :
180227 fields ["assignee" ] = {"name" : ctx .jira .current_user ()}
181228 issue = ctx .jira .create_issue (fields )
182- print (issue )
229+ ctx . print_issue_detail (issue )
183230
184231
185232@main .command ()
0 commit comments