brynq-sdk-jira 3.0.4__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,2 @@
1
+ from .jira import Jira
2
+ from .tempo import Tempo
brynq_sdk_jira/jira.py ADDED
@@ -0,0 +1,183 @@
1
+ import json
2
+ from typing import Union, List, Literal, Optional
3
+ import pandas as pd
4
+ import requests
5
+ from brynq_sdk_brynq import BrynQ
6
+
7
+ class Jira(BrynQ):
8
+ def __init__(self, system_type: Optional[Literal['source', 'target']] = None, debug=False):
9
+ super().__init__()
10
+ credentials = self.interfaces.credentials.get(system="jira", system_type=system_type)
11
+ credentials = credentials.get('data')
12
+ self.base_url = credentials['base_url']
13
+ self.headers = {
14
+ "Authorization": f"Basic {credentials['access_token']}",
15
+ "Content-Type": "application/json"
16
+ }
17
+ self.debug = debug
18
+ self.timeout = 3600
19
+
20
+ def get_issues(self, jql_filter: str = None, jira_filter_id: int = None, get_extra_fields: list = None, expand_fields: list = None) -> pd.DataFrame:
21
+ """
22
+ This method retrieves issues from Jira.
23
+ :param jql_filter: optional filter in jql format
24
+ :param jira_filter_id: optional filter id of predefined filter in jira
25
+ :param get_extra_fields: an optional list of extra fields to retrieve
26
+ :param expand_fields: an optional list of fields to expand
27
+ :return: dataframe with issues
28
+ """
29
+ if jira_filter_id is not None:
30
+ raise ValueError("Jira filter id is no longer supported, use jql_filter instead")
31
+
32
+ # Use new JQL search endpoint
33
+ url = f"{self.base_url}rest/api/3/search/jql"
34
+
35
+ all_issues = []
36
+ next_page_token = None
37
+
38
+ while True:
39
+ payload = {
40
+ "maxResults": 100,
41
+ "fields": ["summary", "issuetype", "timetracking", "timespent", "description", "assignee", "project"]
42
+ }
43
+
44
+ if jql_filter:
45
+ payload["jql"] = jql_filter
46
+
47
+ if get_extra_fields:
48
+ payload["fields"].extend(get_extra_fields)
49
+
50
+ if expand_fields:
51
+ # Convert list to comma-delimited string
52
+ payload["expand"] = ",".join(expand_fields)
53
+
54
+ if next_page_token:
55
+ payload["nextPageToken"] = next_page_token
56
+
57
+ if self.debug:
58
+ print(f"Payload: {payload}")
59
+
60
+ response = requests.post(
61
+ url=url,
62
+ headers=self.headers,
63
+ data=json.dumps(payload),
64
+ timeout=self.timeout
65
+ )
66
+
67
+ if response.status_code == 200:
68
+ response_json = response.json()
69
+ all_issues.extend(response_json.get("issues", []))
70
+
71
+ # Check for next page
72
+ if "nextPageToken" in response_json:
73
+ next_page_token = response_json["nextPageToken"]
74
+ else:
75
+ break
76
+ else:
77
+ raise ConnectionError(f"Error getting issues from Jira with message: {response.status_code, response.text}")
78
+
79
+ if self.debug:
80
+ print(f"Received {len(all_issues)} issues from Jira")
81
+
82
+ df = pd.json_normalize(all_issues)
83
+ return df
84
+
85
+ def get_projects(self) -> pd.DataFrame:
86
+ """
87
+ This method retrieves projects from Jira.
88
+ :return: a dataframe with projects
89
+ """
90
+ total_response = []
91
+ got_all_results = False
92
+ no_of_loops = 0
93
+
94
+ while not got_all_results:
95
+ query = {
96
+ 'startAt': f'{50 * no_of_loops}',
97
+ 'maxResults': '50',
98
+ 'expand': 'description'
99
+ }
100
+ if self.debug:
101
+ print(query)
102
+ response = requests.get(f"{self.base_url}rest/api/3/project/search", headers=self.headers, params=query, timeout=self.timeout)
103
+ if response.status_code == 200:
104
+ response_json = response.json()
105
+ response.raise_for_status()
106
+ no_of_loops += 1
107
+ got_all_results = False if len(response_json['values']) == 50 else True
108
+ total_response += response_json['values']
109
+ else:
110
+ raise ConnectionError(f"Error getting projects from Jira with message: {response.status_code, response.text}")
111
+
112
+ if self.debug:
113
+ print(f"Received {len(total_response)} projects from Jira")
114
+
115
+ df = pd.json_normalize(total_response)
116
+
117
+ return df
118
+
119
+ def get_versions(self, project_key: str) -> pd.DataFrame:
120
+ """
121
+ This method retrieves versions for a given project from Jira.
122
+ :param project_key: The key of the project for which versions are to be retrieved.
123
+ :return: A dataframe with the versions.
124
+ """
125
+ url = f"{self.base_url}rest/api/latest/project/{project_key}/versions"
126
+ response = requests.get(url=url, headers=self.headers, timeout=self.timeout)
127
+ if response.status_code == 200:
128
+ response_json = response.json()
129
+ df = pd.json_normalize(response_json)
130
+ if self.debug:
131
+ print(f"Received {len(df)} versions for project {project_key}")
132
+ return df
133
+ else:
134
+ raise ConnectionError(f"Error getting versions from Jira with message: {response.status_code, response.text}")
135
+
136
+ def get_users(self) -> pd.DataFrame:
137
+ """
138
+ This method retrieves users from Jira.
139
+ :return: a dataframe with users
140
+ """
141
+ start_at = 0
142
+ max_results = 50
143
+ all_users = []
144
+
145
+ while True:
146
+ response = requests.get(f"{self.base_url}rest/api/3/users/search?startAt={start_at}&maxResults={max_results}", headers=self.headers, timeout=self.timeout)
147
+ response.raise_for_status()
148
+ if response.status_code == 200:
149
+ users = response.json() # A list of user objects
150
+ all_users.extend(users) # Add users to the total list
151
+
152
+ # Stop if no more users are returned
153
+ if not users:
154
+ break
155
+
156
+ # Increment startAt for the next page
157
+ start_at += len(users)
158
+ else:
159
+ raise ConnectionError(f"Error getting users from Jira with message: {response.status_code, response.text}")
160
+ if self.debug:
161
+ print(f"Received {len(all_users)} jira users from Jira")
162
+
163
+ df = pd.json_normalize(all_users)
164
+ return df
165
+
166
+ def update_issue(self, issue, fields : dict):
167
+ try:
168
+ url = f"{self.base_url}rest/api/3/issue/{issue}"
169
+ payload = {
170
+ "fields" : fields
171
+ }
172
+
173
+ resp = requests.put(
174
+ url,
175
+ json= payload,
176
+ headers= self.headers,
177
+ timeout=60
178
+ )
179
+
180
+ return resp
181
+ except Exception as e:
182
+ message = "Error updating issue"
183
+ return message
@@ -0,0 +1,288 @@
1
+ import json
2
+ import requests
3
+ from itertools import islice
4
+ from brynq_sdk_brynq import BrynQ
5
+ from typing import Union, List, Literal, Optional
6
+
7
+
8
+ class Tempo(BrynQ):
9
+ def __init__(self, system_type: Optional[Literal['source', 'target']] = None, debug=False):
10
+ super().__init__()
11
+ self.debug = debug
12
+ credentials = self.interfaces.credentials.get(system="jira", system_type=system_type)
13
+ credentials = credentials.get('data')
14
+ self.headers = {
15
+ "Authorization": f"Bearer {credentials['api_token']}",
16
+ "Content-Type": "application/json"
17
+ }
18
+ if self.debug:
19
+ print(self.headers)
20
+ self.timeout = 3600
21
+
22
+ def get_tempo_hours(self, from_date: str = None, to_date: str = None, updated_from: str = None) -> json:
23
+ """
24
+ This function gets hours from Tempo for the specified time period
25
+
26
+ :param from_date: (Optional) string - retrieve results starting with this date
27
+ :param to_date: (Optional) string - retrieve results up to and including this date
28
+ :param updated_from: (Optional) string <yyyy-MM-dd['T'HH:mm:ss]['Z']> - retrieve results that have been updated from this date(e.g "2023-11-16") or date time (e.g "2023-11-06T16:48:59Z")
29
+ :return: json response with results
30
+ """
31
+ total_response = []
32
+ got_all_results = False
33
+ no_of_loops = 0
34
+ parameters = {}
35
+ if from_date is not None:
36
+ parameters.update({"from": from_date})
37
+ if to_date is not None:
38
+ parameters.update({"to": to_date})
39
+ if updated_from is not None:
40
+ parameters.update({"updatedFrom": updated_from})
41
+
42
+ while not got_all_results:
43
+ loop_parameters = parameters | {"limit": 1000, "offset": 1000 * no_of_loops}
44
+ response = requests.get('https://api.tempo.io/4/worklogs', headers=self.headers, params=loop_parameters, timeout=self.timeout)
45
+ if response.status_code == 200:
46
+ response_json = response.json()
47
+ no_of_loops += 1
48
+ got_all_results = False if int(response_json['metadata']['count']) == 1000 else True
49
+ total_response += response_json['results']
50
+ else:
51
+ raise ConnectionError(f"Error getting worklogs from Tempo: {response.status_code, response.text}")
52
+
53
+ if self.debug:
54
+ print(f"Received {len(total_response)} lines from Tempo")
55
+
56
+ return total_response
57
+
58
+ def get_tempo_timesheet_approvals(self, from_date: str, to_date: str, team_id: int = 19) -> json:
59
+ """
60
+ This function retrieves approved timesheet approvals for a given team in Tempo
61
+ over the specified date range.
62
+
63
+ :param from_date: string <yyyy-MM-dd> - retrieve results starting with this date
64
+ :param to_date: string <yyyy-MM-dd> - retrieve results up to and including this date
65
+ :param team_id: int (default 19) - Tempo team ID whose approvals are retrieved
66
+ :return: json response with results
67
+ """
68
+ total_response = []
69
+ got_all_results = False
70
+ no_of_loops = 0
71
+
72
+ parameters = {
73
+ "from": from_date,
74
+ "to": to_date,
75
+ }
76
+
77
+ while not got_all_results:
78
+ loop_parameters = parameters | {"limit": 50, "offset": 50 * no_of_loops}
79
+ url = f"https://api.tempo.io/4/timesheet-approvals/team/{team_id}"
80
+ response = requests.get(
81
+ url,
82
+ headers=self.headers,
83
+ params=loop_parameters,
84
+ timeout=self.timeout
85
+ )
86
+
87
+ if response.status_code == 200:
88
+ response_json = response.json()
89
+ no_of_loops += 1
90
+ got_all_results = False if int(response_json['metadata']['count']) == 50 else True
91
+ total_response += response_json['results']
92
+ else:
93
+ raise ConnectionError(
94
+ f"Error getting timesheet approvals from Tempo: {response.status_code, response.text}"
95
+ )
96
+
97
+ if self.debug:
98
+ print(f"Received {len(total_response)} timesheet approvals from Tempo")
99
+
100
+ return total_response
101
+
102
+ def call_api(self, url: str, limit: int = 50) -> list[dict]:
103
+ """
104
+ Calls the Tempo API and retrieves all paginated results for a given endpoint.
105
+
106
+ Args:
107
+ url (str): The API endpoint URL to call.
108
+ limit (int): Max results to fetch per request (default 50).
109
+
110
+ Returns:
111
+ list[dict]: A list of all results across all pages.
112
+ """
113
+ all_results = []
114
+ offset = 0
115
+ while True:
116
+ querystring = {"limit": str(limit), "offset": str(offset)}
117
+ response = requests.get(url, headers=self.headers, params=querystring)
118
+ response.raise_for_status()
119
+ data = response.json()
120
+
121
+ # append results
122
+ results = data.get("results", [])
123
+ all_results.extend(results)
124
+
125
+ # pagination check
126
+ count = data.get("metadata", {}).get("count", 0)
127
+ if count < limit:
128
+ break
129
+
130
+ offset += limit
131
+
132
+ return all_results
133
+
134
+ def get_tempo_teams(self, team_members: List[str] = None, name: str = None) -> json:
135
+ """
136
+ Fetches teams from the Tempo API in smaller batches to prevent long URLs if team_members is specified,
137
+ otherwise, retrieves a list of all existing Teams.
138
+
139
+ :param team_members: (Optional) List of Jira user account IDs to filter teams.
140
+ :param name: (Optional) Name of the team to filter teams.
141
+ :return: A json response containing team details.
142
+ """
143
+ total_response = []
144
+
145
+ # Split team members into smaller chunks (avoid long URLs)
146
+ team_member_chunks = self._chunk_list(team_members, 50) if team_members else [None]
147
+
148
+ for team_chunk in team_member_chunks:
149
+ parameters = {"limit": 1000, "offset": 0}
150
+ if team_chunk:
151
+ parameters["teamMembers"] = ",".join(team_chunk) # Send fewer team members at a time
152
+ if name:
153
+ parameters["name"] = name
154
+ got_all_results = False
155
+ no_of_loops = 0
156
+
157
+ while not got_all_results:
158
+ parameters["offset"] = 1000 * no_of_loops
159
+ response = requests.get('https://api.tempo.io/4/teams', headers=self.headers, params=parameters, timeout=self.timeout)
160
+ if response.status_code == 200:
161
+ response_json = response.json()
162
+ total_response.extend(response_json["results"])
163
+ got_all_results = False if int(response_json['metadata']['count']) == 1000 else True
164
+ no_of_loops += 1
165
+ else:
166
+ raise ConnectionError(f"Error getting teams from Tempo: {response.status_code}, {response.text}")
167
+
168
+ if self.debug:
169
+ print(f"Received {len(total_response)} teams from Tempo")
170
+ return total_response
171
+
172
+ def get_tempo_team_members(self, team_ids: List[int]) -> json:
173
+ """
174
+ Fetches members of multiple teams from the Tempo API iteratively.
175
+
176
+ :param team_ids: List of Tempo team IDs to retrieve members from.
177
+ :return: A json response containing team members' details.
178
+ """
179
+ total_response = []
180
+
181
+ for team_id in team_ids:
182
+ got_all_results = False
183
+ no_of_loops = 0
184
+
185
+ while not got_all_results:
186
+ parameters = {"limit": 1000, "offset": 1000 * no_of_loops}
187
+ response = requests.get(f'https://api.tempo.io/4/teams/{team_id}/members', headers=self.headers, params=parameters, timeout=self.timeout)
188
+ if response.status_code == 200:
189
+ response_json = response.json()
190
+ total_response.extend(response_json["results"])
191
+ got_all_results = False if int(response_json.get('metadata', {}).get('count', 0)) == 1000 else True
192
+ no_of_loops += 1
193
+ else:
194
+ raise ConnectionError(f"Error getting team members from Tempo: {response.status_code}, {response.text}")
195
+
196
+ if self.debug:
197
+ print(f"Received {len(total_response)} team members from Tempo")
198
+ return total_response
199
+
200
+ def get_accounts(self) -> json:
201
+ """
202
+ Fetches account details from the Tempo API in batches to handle large datasets.
203
+
204
+ :return: A json object containing account details.
205
+ """
206
+ total_response = []
207
+ got_all_results = False
208
+ no_of_loops = 0
209
+ parameters = {}
210
+
211
+ while not got_all_results:
212
+ loop_parameters = parameters | {"limit": 1000, "offset": 1000 * no_of_loops}
213
+ response = requests.get('https://api.tempo.io/4/accounts', headers=self.headers, params=loop_parameters, timeout=self.timeout)
214
+ if response.status_code == 200:
215
+ response_json = response.json()
216
+ no_of_loops += 1
217
+ got_all_results = False if int(response_json['metadata']['count']) == 1000 else True
218
+ total_response += response_json['results']
219
+ else:
220
+ raise ConnectionError(f"Error getting accounts from Tempo: {response.status_code}, {response.text}")
221
+
222
+ if self.debug:
223
+ print(f"Received {len(total_response)} accounts from Tempo")
224
+ return total_response
225
+
226
+ def get_worklog_accounts(self, account_key: str, from_date: str = None, to_date: str = None, updated_from: str = None) -> json:
227
+ """
228
+ Fetches worklog data for a given account key from the Tempo API.
229
+
230
+ :param account_key: (Required) string - The account key for which worklog data is required.
231
+ :param from_date: (Optional) string - retrieve results starting with this date
232
+ :param to_date: (Optional) string - retrieve results up to and including this date
233
+ :param updated_from: (Optional) string <yyyy-MM-dd['T'HH:mm:ss]['Z']> - retrieve results that have been updated from this date(e.g "2023-11-16") or date time (e.g "2023-11-06T16:48:59Z")
234
+ :return: A json containing worklog details.
235
+ """
236
+ total_response = []
237
+ got_all_results = False
238
+ no_of_loops = 0
239
+ parameters = {}
240
+ if from_date is not None:
241
+ parameters.update({"from": from_date})
242
+ if to_date is not None:
243
+ parameters.update({"to": to_date})
244
+ if updated_from is not None:
245
+ parameters.update({"updatedFrom": updated_from})
246
+
247
+ while not got_all_results:
248
+ loop_parameters = parameters | {"limit": 1000, "offset": 1000 * no_of_loops}
249
+ response = requests.get(f"https://api.tempo.io/4/worklogs/account/{account_key}", headers=self.headers, params=loop_parameters, timeout=self.timeout)
250
+ if response.status_code == 200:
251
+ response_json = response.json()
252
+ total_response.extend(response_json.get("results", []))
253
+ got_all_results = False if int(response_json.get('metadata', {}).get('count', 0)) == 1000 else True
254
+ no_of_loops += 1
255
+ else:
256
+ raise ConnectionError(f"Failed to fetch data for account key {account_key}: {response.status_code}, {response.text}")
257
+
258
+ if self.debug:
259
+ print(f"Received {len(total_response)} worklogs for account key {account_key}")
260
+
261
+ return total_response
262
+
263
+ def update_worklog(self, worklog_id: Union[str, int], data: Union[str, dict]) -> requests.Response:
264
+ """
265
+ Updates a Tempo worklog by ID.
266
+
267
+ Args:
268
+ worklog_id (str | int): ID of the worklog to update.
269
+ data (str | dict): The payload to send in the update request (JSON string or dict).
270
+
271
+ Returns:
272
+ requests.Response: The HTTP response object from Tempo API.
273
+ """
274
+ url = f"https://api.tempo.io/4/worklogs/{worklog_id}"
275
+
276
+ # Ensure we send valid JSON
277
+ if isinstance(data, dict):
278
+ payload = json.dumps(data)
279
+ else:
280
+ payload = data
281
+
282
+ response = requests.put(url, headers=self.headers, data=payload, timeout=self.timeout)
283
+ return response
284
+
285
+ def _chunk_list(self, data_list, chunk_size):
286
+ """Splits a list into chunks of `chunk_size`."""
287
+ it = iter(data_list)
288
+ return iter(lambda: list(islice(it, chunk_size)), [])
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: brynq_sdk_jira
3
+ Version: 3.0.4
4
+ Summary: JIRA wrapper from BrynQ
5
+ Author: BrynQ
6
+ Author-email: support@brynq.com
7
+ License: BrynQ License
8
+ Requires-Dist: brynq-sdk-brynq<5,>=4
9
+ Requires-Dist: pandas<3,>=1
10
+ Requires-Dist: requests<=3,>=2
11
+ Dynamic: author
12
+ Dynamic: author-email
13
+ Dynamic: description
14
+ Dynamic: license
15
+ Dynamic: requires-dist
16
+ Dynamic: summary
17
+
18
+ JIRA wrapper from BrynQ
@@ -0,0 +1,7 @@
1
+ brynq_sdk_jira/__init__.py,sha256=sw18oQvBYeGrqYrTXfMANQSE0B3BJ2TFetEy06h8wAU,47
2
+ brynq_sdk_jira/jira.py,sha256=KH2bBOWkWYpDniIWbRc31n8J62j1Hx-5amgKWItYLPM,6797
3
+ brynq_sdk_jira/tempo.py,sha256=mcRXfGHbxxS9cVlEnflzCv8RXyKY4Rj9L_ErdLkR5Yo,12505
4
+ brynq_sdk_jira-3.0.4.dist-info/METADATA,sha256=kfHATSRlPw9EXWzsv7GGVt7UscXV8Lx5T99OQfox_YY,397
5
+ brynq_sdk_jira-3.0.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
6
+ brynq_sdk_jira-3.0.4.dist-info/top_level.txt,sha256=e4NQdxchgAtFyXeuPqqN4Mbx2BbrUk2Cf7vNKRQrcys,15
7
+ brynq_sdk_jira-3.0.4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ brynq_sdk_jira