devopsdriver 0.1.34__tar.gz → 0.1.35__tar.gz
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.
- {devopsdriver-0.1.34 → devopsdriver-0.1.35}/PKG-INFO +8 -4
- {devopsdriver-0.1.34 → devopsdriver-0.1.35}/README.md +5 -3
- {devopsdriver-0.1.34 → devopsdriver-0.1.35}/devopsdriver/__init__.py +3 -1
- devopsdriver-0.1.35/devopsdriver/azure/__init__.py +10 -0
- devopsdriver-0.1.35/devopsdriver/azure/clients.py +53 -0
- devopsdriver-0.1.35/devopsdriver/azure/workitem/__init__.py +4 -0
- devopsdriver-0.1.35/devopsdriver/azure/workitem/client.py +88 -0
- devopsdriver-0.1.35/devopsdriver/azure/workitem/wiql.py +282 -0
- devopsdriver-0.1.35/devopsdriver/azure/workitem/workitem.py +53 -0
- {devopsdriver-0.1.34 → devopsdriver-0.1.35}/devopsdriver/settings.py +9 -2
- {devopsdriver-0.1.34 → devopsdriver-0.1.35}/devopsdriver.egg-info/PKG-INFO +8 -4
- devopsdriver-0.1.35/devopsdriver.egg-info/SOURCES.txt +21 -0
- devopsdriver-0.1.35/devopsdriver.egg-info/requires.txt +4 -0
- {devopsdriver-0.1.34 → devopsdriver-0.1.35}/pyproject.toml +2 -0
- devopsdriver-0.1.35/tests/test_azure_clients.py +144 -0
- devopsdriver-0.1.35/tests/test_azure_workitem.py +95 -0
- devopsdriver-0.1.35/tests/test_azure_workitem_client.py +61 -0
- devopsdriver-0.1.35/tests/test_azure_workitem_wiql.py +71 -0
- {devopsdriver-0.1.34 → devopsdriver-0.1.35}/tests/test_settings.py +24 -59
- devopsdriver-0.1.34/devopsdriver.egg-info/SOURCES.txt +0 -11
- devopsdriver-0.1.34/devopsdriver.egg-info/requires.txt +0 -2
- {devopsdriver-0.1.34 → devopsdriver-0.1.35}/LICENSE +0 -0
- {devopsdriver-0.1.34 → devopsdriver-0.1.35}/devopsdriver.egg-info/dependency_links.txt +0 -0
- {devopsdriver-0.1.34 → devopsdriver-0.1.35}/devopsdriver.egg-info/top_level.txt +0 -0
- {devopsdriver-0.1.34 → devopsdriver-0.1.35}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: devopsdriver
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.35
|
|
4
4
|
Summary: DevOps tools
|
|
5
5
|
Author-email: Marc Page <marcallenpage@gmail.com>
|
|
6
6
|
License: This is free and unencumbered software released into the public domain.
|
|
@@ -48,10 +48,15 @@ Description-Content-Type: text/markdown
|
|
|
48
48
|
License-File: LICENSE
|
|
49
49
|
Requires-Dist: PyYAML==6.0.1
|
|
50
50
|
Requires-Dist: keyring==25.0.0
|
|
51
|
+
Requires-Dist: setuptools==69.0.2
|
|
52
|
+
Requires-Dist: azure-devops==7.1.0b4
|
|
51
53
|
|
|
52
54
|

|
|
53
|
-
[](https://pypi.org/project/devopsdriver/0.1.35/)
|
|
54
56
|
[](https://github.com/marcpage/devops-driver?tab=Unlicense-1-ov-file#readme)
|
|
57
|
+
[](https://github.com/marcpage/devops-driver/graphs/contributors)
|
|
58
|
+
[](http://makeapullrequest.com)
|
|
59
|
+
|
|
55
60
|
[](https://github.com/marcpage/devops-driver/commits)
|
|
56
61
|
[](https://github.com/marcpage/devops-driver/commits)
|
|
57
62
|
[](https://github.com/marcpage/devops-driver)
|
|
@@ -61,8 +66,6 @@ Requires-Dist: keyring==25.0.0
|
|
|
61
66
|
[](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
|
|
62
67
|
[](https://github.com/marcpage/devops-driver/issues)
|
|
63
68
|
[](https://github.com/marcpage/devops-driver/pulls)
|
|
64
|
-
[](https://github.com/marcpage/devops-driver/graphs/contributors)
|
|
65
|
-
[](http://makeapullrequest.com)
|
|
66
69
|
|
|
67
70
|
[](https://github.com/marcpage?tab=followers)
|
|
68
71
|
[](https://github.com/marcpage/devops-driver/watchers)
|
|
@@ -70,3 +73,4 @@ Requires-Dist: keyring==25.0.0
|
|
|
70
73
|
# devops-driver
|
|
71
74
|
|
|
72
75
|
Devops-driver is a set of tools to help streamline developer's experience. It is a collection of tools to help gain insights into various processes.
|
|
76
|
+
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|

|
|
2
|
-
[](https://pypi.org/project/devopsdriver/0.1.35/)
|
|
3
3
|
[](https://github.com/marcpage/devops-driver?tab=Unlicense-1-ov-file#readme)
|
|
4
|
+
[](https://github.com/marcpage/devops-driver/graphs/contributors)
|
|
5
|
+
[](http://makeapullrequest.com)
|
|
6
|
+
|
|
4
7
|
[](https://github.com/marcpage/devops-driver/commits)
|
|
5
8
|
[](https://github.com/marcpage/devops-driver/commits)
|
|
6
9
|
[](https://github.com/marcpage/devops-driver)
|
|
@@ -10,8 +13,6 @@
|
|
|
10
13
|
[](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
|
|
11
14
|
[](https://github.com/marcpage/devops-driver/issues)
|
|
12
15
|
[](https://github.com/marcpage/devops-driver/pulls)
|
|
13
|
-
[](https://github.com/marcpage/devops-driver/graphs/contributors)
|
|
14
|
-
[](http://makeapullrequest.com)
|
|
15
16
|
|
|
16
17
|
[](https://github.com/marcpage?tab=followers)
|
|
17
18
|
[](https://github.com/marcpage/devops-driver/watchers)
|
|
@@ -19,3 +20,4 @@
|
|
|
19
20
|
# devops-driver
|
|
20
21
|
|
|
21
22
|
Devops-driver is a set of tools to help streamline developer's experience. It is a collection of tools to help gain insights into various processes.
|
|
23
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
""" initialize azure module """
|
|
2
|
+
|
|
3
|
+
# re export symbols for easier use
|
|
4
|
+
from .clients import Azure
|
|
5
|
+
|
|
6
|
+
from .workitem import WorkItem
|
|
7
|
+
from .workitem.wiql import Wiql, Value, Field
|
|
8
|
+
from .workitem.wiql import Ascending, Descending, And, Or
|
|
9
|
+
from .workitem.wiql import Equal, NotEqual, LessThanOrEqual, GreaterThanOrEqual
|
|
10
|
+
from .workitem.wiql import IsEmpty, IsNotEmpty, LessThan, GreaterThan
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
""" Establish a connection to Azure Devops
|
|
5
|
+
|
|
6
|
+
API Documented here:
|
|
7
|
+
https://github.com/microsoft/azure-devops-python-api
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from azure.devops.connection import Connection as AzureConnection
|
|
11
|
+
from msrest.authentication import BasicAuthentication as MSBasicAuthentication
|
|
12
|
+
|
|
13
|
+
from devopsdriver.settings import Settings
|
|
14
|
+
from devopsdriver.azure.workitem.client import Client as WIClient
|
|
15
|
+
|
|
16
|
+
CONNECTION = AzureConnection
|
|
17
|
+
AUTHENTICATION = MSBasicAuthentication
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Azure: # pylint: disable=too-few-public-methods
|
|
21
|
+
"""A connection to Azure clients"""
|
|
22
|
+
|
|
23
|
+
SUPPORTED_CLIENTS = {"workitem", "pipeline"}
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self, settings: Settings = None, token: str = None, url: str = None, **clients
|
|
27
|
+
):
|
|
28
|
+
unsupported_clients = set(clients) - Azure.SUPPORTED_CLIENTS
|
|
29
|
+
assert not unsupported_clients, f"{unsupported_clients} not supported"
|
|
30
|
+
settings = (
|
|
31
|
+
Settings(__file__).key("secrets")
|
|
32
|
+
if settings is None and token is None and url is None
|
|
33
|
+
else settings
|
|
34
|
+
)
|
|
35
|
+
assert (token or "azure.token" in settings) and (
|
|
36
|
+
url or "azure.url" in settings
|
|
37
|
+
), "azure.token and azure.url not found in:\n" + "\n".join(
|
|
38
|
+
settings.search_files
|
|
39
|
+
)
|
|
40
|
+
url = settings["azure.url"] if url is None else url
|
|
41
|
+
token = settings["azure.token"] if token is None else token
|
|
42
|
+
self.connection = CONNECTION(base_url=url, creds=AUTHENTICATION("", token))
|
|
43
|
+
client_calls = {
|
|
44
|
+
"workitem": self.connection.clients_v7_1.get_work_item_tracking_client
|
|
45
|
+
}
|
|
46
|
+
self.workitem = WIClient(Azure.__client("workitem", clients, client_calls))
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def __client(name: str, clients: dict, calls: dict) -> any:
|
|
50
|
+
if clients and not clients.get(name, False):
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
return calls[name]()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
""" Azure WorkItem Client """
|
|
4
|
+
|
|
5
|
+
from azure.devops.v7_1.work_item_tracking.models import Wiql as AzureWiql
|
|
6
|
+
from azure.devops.v7_1.work_item_tracking.models import WorkItem as AzureWorkItem
|
|
7
|
+
from azure.devops.v7_1.work_item_tracking.models import TeamContext
|
|
8
|
+
from azure.devops.v7_1.work_item_tracking.models import WorkItemQueryResult
|
|
9
|
+
from devopsdriver.azure.workitem import WorkItem
|
|
10
|
+
from devopsdriver.azure.workitem.wiql import Wiql
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Client:
|
|
14
|
+
"""Wraps work item client"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, client):
|
|
17
|
+
self.client = client
|
|
18
|
+
|
|
19
|
+
def query(
|
|
20
|
+
self,
|
|
21
|
+
wiql: Wiql | str,
|
|
22
|
+
team_context: TeamContext = None,
|
|
23
|
+
time_precision: bool = None,
|
|
24
|
+
top: int = None,
|
|
25
|
+
) -> WorkItemQueryResult:
|
|
26
|
+
"""Perform a wiql query
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
wiql (Wiql | str): The query
|
|
30
|
+
team_context (TeamContext, optional): context object. Defaults to None.
|
|
31
|
+
time_precision (bool, optional): True for precision time. Defaults to None.
|
|
32
|
+
top (int, optional): Count of items to get. Defaults to None.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
WorkItemQueryResult: The results
|
|
36
|
+
"""
|
|
37
|
+
return self.client.query_by_wiql(
|
|
38
|
+
AzureWiql(query=str(wiql)),
|
|
39
|
+
team_context=team_context,
|
|
40
|
+
time_precision=time_precision,
|
|
41
|
+
top=top,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def history( # pylint: disable=too-many-arguments
|
|
45
|
+
self,
|
|
46
|
+
wi_id: int,
|
|
47
|
+
project: str = None,
|
|
48
|
+
top: int = None,
|
|
49
|
+
skip: int = None,
|
|
50
|
+
expand: str = None,
|
|
51
|
+
) -> list[AzureWorkItem]:
|
|
52
|
+
"""Simple wrapper around get_revisions"""
|
|
53
|
+
return self.client.get_revisions(wi_id, project, top, skip, expand)
|
|
54
|
+
|
|
55
|
+
def find_ids(self, wiql: Wiql | str, top: int = None) -> list[int]:
|
|
56
|
+
"""Given a query, find the work item ids
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
wiql (Wiql | str): The query
|
|
60
|
+
top (int, optional): The number of results to return. Defaults to None.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
list: List of item ids
|
|
64
|
+
"""
|
|
65
|
+
if isinstance(wiql, Wiql):
|
|
66
|
+
wiql.select("Id")
|
|
67
|
+
|
|
68
|
+
found = self.query(wiql, top=top)
|
|
69
|
+
# top-level items: as_of, columns, query_results_type, query_type, work_items
|
|
70
|
+
# work_items fields: id, url
|
|
71
|
+
# query_results_type: workItem
|
|
72
|
+
# query_type: flat
|
|
73
|
+
# columns: list of name, reference_name, url
|
|
74
|
+
return [i.id for i in found.work_items]
|
|
75
|
+
|
|
76
|
+
def find(self, wiql: Wiql | str, top: int = None) -> list[list[WorkItem]]:
|
|
77
|
+
"""Gets the full history of items found in a WIQL search
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
wiql (Wiql | str): The query
|
|
81
|
+
top (int, optional): The number of work items to return. Defaults to None.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
list[list[WorkItem]]: List of work items, each is a history of work items
|
|
85
|
+
"""
|
|
86
|
+
return [
|
|
87
|
+
[WorkItem(e) for e in self.history(i)] for i in self.find_ids(wiql, top)
|
|
88
|
+
]
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
""" Builds a WIQL query
|
|
4
|
+
https://learn.microsoft.com/en-us/azure/devops/boards/queries/wiql-syntax?view=azure-devops
|
|
5
|
+
|
|
6
|
+
SELECT
|
|
7
|
+
[System.Id],
|
|
8
|
+
[System.AssignedTo],
|
|
9
|
+
[System.State],
|
|
10
|
+
[System.Title],
|
|
11
|
+
[System.Tags]
|
|
12
|
+
FROM workitems
|
|
13
|
+
WHERE
|
|
14
|
+
[System.TeamProject] = 'Design Agile'
|
|
15
|
+
AND [System.WorkItemType] = 'User Story'
|
|
16
|
+
AND [System.State] = 'Active'
|
|
17
|
+
ORDER BY [System.ChangedDate] DESC
|
|
18
|
+
ASOF '02-11-2020'
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
from datetime import datetime, date
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Field: # pylint: disable=too-few-public-methods
|
|
26
|
+
"""column name"""
|
|
27
|
+
|
|
28
|
+
SYSTEM = {
|
|
29
|
+
"Id",
|
|
30
|
+
"AssignTo",
|
|
31
|
+
"State",
|
|
32
|
+
"Title",
|
|
33
|
+
"Tags",
|
|
34
|
+
"TeamProject",
|
|
35
|
+
"WorkItemType",
|
|
36
|
+
"ChangedDate",
|
|
37
|
+
"CreatedDate",
|
|
38
|
+
}
|
|
39
|
+
COMMON = {"Priority"}
|
|
40
|
+
|
|
41
|
+
def __init__(self, value: str):
|
|
42
|
+
self.value = value
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
if self.value in Field.SYSTEM:
|
|
46
|
+
prefix = "System."
|
|
47
|
+
|
|
48
|
+
elif self.value in Field.COMMON:
|
|
49
|
+
prefix = "Microsoft.VSTS.Common."
|
|
50
|
+
|
|
51
|
+
else:
|
|
52
|
+
prefix = ""
|
|
53
|
+
|
|
54
|
+
return f"[{prefix}{self.value}]"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OrderBy: # pylint: disable=too-few-public-methods
|
|
58
|
+
"""Order by field"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, field: Field | str, order: str = "DESC"):
|
|
61
|
+
self.field = field if isinstance(field, Field) else Field(field)
|
|
62
|
+
self.order = order
|
|
63
|
+
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
return f"{self.field} {self.order}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Ascending(OrderBy): # pylint: disable=too-few-public-methods
|
|
69
|
+
"""Order by field ascending"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, field: Field | str):
|
|
72
|
+
super().__init__(field, "ASC")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Descending(OrderBy): # pylint: disable=too-few-public-methods
|
|
76
|
+
"""Order by field descending"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, field: Field | str):
|
|
79
|
+
super().__init__(field, "DESC")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Value: # pylint: disable=too-few-public-methods
|
|
83
|
+
"""A constant value"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, value: date | datetime | int | float | str):
|
|
86
|
+
self.value = value
|
|
87
|
+
|
|
88
|
+
def __str__(self) -> str:
|
|
89
|
+
if isinstance(self.value, datetime):
|
|
90
|
+
return self.value.strftime("%Y-%m-%d %H:%M:%S")
|
|
91
|
+
|
|
92
|
+
if isinstance(self.value, date):
|
|
93
|
+
return self.value.strftime("%Y-%m-%d")
|
|
94
|
+
|
|
95
|
+
if isinstance(self.value, int):
|
|
96
|
+
return str(self.value)
|
|
97
|
+
|
|
98
|
+
if isinstance(self.value, float):
|
|
99
|
+
return f"{self.value:0.3f}"
|
|
100
|
+
|
|
101
|
+
if isinstance(self.value, str):
|
|
102
|
+
return f'"{self.value}"' # TODO: escape value # pylint: disable=fixme
|
|
103
|
+
|
|
104
|
+
raise AssertionError("Unknown type")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class IsEmpty: # pylint: disable=too-few-public-methods
|
|
108
|
+
"""Compare a field to a value"""
|
|
109
|
+
|
|
110
|
+
def __init__(self, field: Field | str):
|
|
111
|
+
self.field = field if isinstance(field, Field) else Field(field)
|
|
112
|
+
|
|
113
|
+
def __str__(self) -> str:
|
|
114
|
+
return f"{str(self.field)} IS EMPTY"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class IsNotEmpty: # pylint: disable=too-few-public-methods
|
|
118
|
+
"""Compare a field to a value"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, field: Field | str):
|
|
121
|
+
self.field = field if isinstance(field, Field) else Field(field)
|
|
122
|
+
|
|
123
|
+
def __str__(self) -> str:
|
|
124
|
+
return f"{str(self.field)} IS NOT EMPTY"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Compare: # pylint: disable=too-few-public-methods
|
|
128
|
+
"""Compare a field to a value"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
field: Field | str,
|
|
133
|
+
value: Value | str | date | datetime | int | float,
|
|
134
|
+
operator: str,
|
|
135
|
+
):
|
|
136
|
+
self.left = field if isinstance(field, Field) else Field(field)
|
|
137
|
+
self.right = value if isinstance(value, Value) else Value(value)
|
|
138
|
+
self.operator = operator
|
|
139
|
+
|
|
140
|
+
def __str__(self) -> str:
|
|
141
|
+
return f"{str(self.left)} {self.operator} {str(self.right)}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Equal(Compare): # pylint: disable=too-few-public-methods
|
|
145
|
+
"""checks for equality"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self, field: Field | str, value: Value | str | date | datetime | int | float
|
|
149
|
+
):
|
|
150
|
+
super().__init__(field, value, "=")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class NotEqual(Compare): # pylint: disable=too-few-public-methods
|
|
154
|
+
"""checks for equality"""
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self, field: Field | str, value: Value | str | date | datetime | int | float
|
|
158
|
+
):
|
|
159
|
+
super().__init__(field, value, "<>")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class LessThan(Compare): # pylint: disable=too-few-public-methods
|
|
163
|
+
"""checks for lass than"""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self, field: Field | str, value: Value | str | date | datetime | int | float
|
|
167
|
+
):
|
|
168
|
+
super().__init__(field, value, "<")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class GreaterThan(Compare): # pylint: disable=too-few-public-methods
|
|
172
|
+
"""checks for greater than"""
|
|
173
|
+
|
|
174
|
+
def __init__(
|
|
175
|
+
self, field: Field | str, value: Value | str | date | datetime | int | float
|
|
176
|
+
):
|
|
177
|
+
super().__init__(field, value, ">")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class LessThanOrEqual(Compare): # pylint: disable=too-few-public-methods
|
|
181
|
+
"""checks for less than or equal"""
|
|
182
|
+
|
|
183
|
+
def __init__(
|
|
184
|
+
self, field: Field | str, value: Value | str | date | datetime | int | float
|
|
185
|
+
):
|
|
186
|
+
super().__init__(field, value, "<=")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class GreaterThanOrEqual(Compare): # pylint: disable=too-few-public-methods
|
|
190
|
+
"""checks for greater than or equal"""
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self, field: Field | str, value: Value | str | date | datetime | int | float
|
|
194
|
+
):
|
|
195
|
+
super().__init__(field, value, ">=")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Expression: # pylint: disable=too-few-public-methods
|
|
199
|
+
"""Join several compares"""
|
|
200
|
+
|
|
201
|
+
def __init__(self, operator: str, *compares: list[Compare]):
|
|
202
|
+
self.operator = operator
|
|
203
|
+
self.expressions = compares
|
|
204
|
+
|
|
205
|
+
def __str__(self) -> str:
|
|
206
|
+
return f" {self.operator} ".join(str(e) for e in self.expressions)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class And(Expression): # pylint: disable=too-few-public-methods
|
|
210
|
+
"""Join compares via AND"""
|
|
211
|
+
|
|
212
|
+
def __init__(self, *compares: list[Compare | Expression]):
|
|
213
|
+
super().__init__("AND", *compares)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class Or(Expression): # pylint: disable=too-few-public-methods
|
|
217
|
+
"""join compares via OR"""
|
|
218
|
+
|
|
219
|
+
def __init__(self, *compares: list[Compare | Expression]):
|
|
220
|
+
super().__init__("OR", *compares)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class Wiql:
|
|
224
|
+
"""Build a WIQL query"""
|
|
225
|
+
|
|
226
|
+
def __init__(self):
|
|
227
|
+
self.selected = [Field("Id")]
|
|
228
|
+
self.search = None
|
|
229
|
+
self.order = []
|
|
230
|
+
self.snapshot = None
|
|
231
|
+
|
|
232
|
+
def select(self, *fields: list[Field | str]):
|
|
233
|
+
"""The fields to select
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Builder: Returns self for chaining
|
|
237
|
+
"""
|
|
238
|
+
self.selected = [f if isinstance(f, Field) else Field(f) for f in fields]
|
|
239
|
+
return self
|
|
240
|
+
|
|
241
|
+
def where(self, expression: Compare | And | Or):
|
|
242
|
+
"""Search criteria
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
expression (Expression|Compare): An expression of what to search for.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Builder: self for chaining calls
|
|
249
|
+
"""
|
|
250
|
+
self.search = expression
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
def order_by(self, *orders):
|
|
254
|
+
"""Set the fields to order the results by
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Builder: Returns self for chaining
|
|
258
|
+
"""
|
|
259
|
+
self.order = orders
|
|
260
|
+
return self
|
|
261
|
+
|
|
262
|
+
def asof(self, stamp: Value | date | datetime | str):
|
|
263
|
+
"""Set the view of the data
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
stamp (Value): The date or datetime in a Value
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Builder: self for chainingd
|
|
270
|
+
"""
|
|
271
|
+
assert isinstance(stamp, (Value, date, datetime, str)), stamp
|
|
272
|
+
self.snapshot = stamp if isinstance(stamp, (Value, str)) else Value(stamp)
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
def __str__(self) -> str:
|
|
276
|
+
select = ", ".join(str(s) for s in self.selected)
|
|
277
|
+
where = f" WHERE {self.search}" if self.search else ""
|
|
278
|
+
order = (
|
|
279
|
+
f" ORDER BY {', '.join(str(o) for o in self.order)}" if self.order else ""
|
|
280
|
+
)
|
|
281
|
+
asof = f" ASOF {str(self.snapshot)}" if self.snapshot else ""
|
|
282
|
+
return f"SELECT {select} FROM workitems{where}{order}{asof}"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
""" An Azure Devops WorkItem """
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from azure.devops.v7_1.work_item_tracking.models import WorkItem as AzureWorkItem
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WorkItem: # pylint: disable=too-few-public-methods
|
|
10
|
+
"""Azure WorkItem"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, work_item: AzureWorkItem):
|
|
13
|
+
self.raw = work_item
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def __matches_field(name: str, field: str) -> bool:
|
|
17
|
+
name = name.lower()
|
|
18
|
+
field = field.lower()
|
|
19
|
+
|
|
20
|
+
if name == field:
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
if name == field.replace(".", "_"):
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
if name == field.split(".")[-1]:
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _parse_field(name: str, data: dict) -> any:
|
|
33
|
+
assert name and data
|
|
34
|
+
found = [f for f in data if WorkItem.__matches_field(name, f)]
|
|
35
|
+
|
|
36
|
+
if len(found) == 1:
|
|
37
|
+
return (
|
|
38
|
+
WorkItem._Dict(data[found[0]])
|
|
39
|
+
if isinstance(data[found[0]], dict)
|
|
40
|
+
else data[found[0]]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if "fields" in data:
|
|
44
|
+
return WorkItem._parse_field(name, data["fields"])
|
|
45
|
+
|
|
46
|
+
raise AttributeError(f"'WorkItem' object has no attribute '{name}'")
|
|
47
|
+
|
|
48
|
+
class _Dict(dict):
|
|
49
|
+
def __getattr__(self, name: str) -> Any:
|
|
50
|
+
return WorkItem._parse_field(name, self)
|
|
51
|
+
|
|
52
|
+
def __getattr__(self, name: str) -> Any:
|
|
53
|
+
return WorkItem._parse_field(name, self.raw.as_dict())
|
|
@@ -304,6 +304,9 @@ class Settings:
|
|
|
304
304
|
"""
|
|
305
305
|
return self.__lookup(key, check=True)
|
|
306
306
|
|
|
307
|
+
def __contains__(self, key: str) -> bool:
|
|
308
|
+
return self.has(key)
|
|
309
|
+
|
|
307
310
|
def __getitem__(self, key: str) -> any:
|
|
308
311
|
if not self.has(key):
|
|
309
312
|
raise KeyError(key)
|
|
@@ -362,15 +365,19 @@ def main() -> None:
|
|
|
362
365
|
|
|
363
366
|
args = list(ARGV[1:])
|
|
364
367
|
|
|
365
|
-
if "--
|
|
366
|
-
args.remove("--
|
|
368
|
+
if "--secrets" in args:
|
|
369
|
+
args.remove("--secrets")
|
|
367
370
|
|
|
368
371
|
for secret, key in settings.secrets.items():
|
|
372
|
+
PRINT(f"secret: {secret} key: {key}")
|
|
373
|
+
|
|
369
374
|
if not settings.has(secret):
|
|
370
375
|
value = GET_PASS(f"{secret} ({key}): ")
|
|
371
376
|
|
|
372
377
|
if value:
|
|
373
378
|
SET_PASSWORD(*Settings.split_key(key), value)
|
|
379
|
+
else:
|
|
380
|
+
PRINT("\tValue set")
|
|
374
381
|
|
|
375
382
|
for arg in args:
|
|
376
383
|
PRINT(settings.get(arg))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: devopsdriver
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.35
|
|
4
4
|
Summary: DevOps tools
|
|
5
5
|
Author-email: Marc Page <marcallenpage@gmail.com>
|
|
6
6
|
License: This is free and unencumbered software released into the public domain.
|
|
@@ -48,10 +48,15 @@ Description-Content-Type: text/markdown
|
|
|
48
48
|
License-File: LICENSE
|
|
49
49
|
Requires-Dist: PyYAML==6.0.1
|
|
50
50
|
Requires-Dist: keyring==25.0.0
|
|
51
|
+
Requires-Dist: setuptools==69.0.2
|
|
52
|
+
Requires-Dist: azure-devops==7.1.0b4
|
|
51
53
|
|
|
52
54
|

|
|
53
|
-
[](https://pypi.org/project/devopsdriver/0.1.35/)
|
|
54
56
|
[](https://github.com/marcpage/devops-driver?tab=Unlicense-1-ov-file#readme)
|
|
57
|
+
[](https://github.com/marcpage/devops-driver/graphs/contributors)
|
|
58
|
+
[](http://makeapullrequest.com)
|
|
59
|
+
|
|
55
60
|
[](https://github.com/marcpage/devops-driver/commits)
|
|
56
61
|
[](https://github.com/marcpage/devops-driver/commits)
|
|
57
62
|
[](https://github.com/marcpage/devops-driver)
|
|
@@ -61,8 +66,6 @@ Requires-Dist: keyring==25.0.0
|
|
|
61
66
|
[](https://github.com/marcpage/devops-driver/blob/main/Makefile#L4)
|
|
62
67
|
[](https://github.com/marcpage/devops-driver/issues)
|
|
63
68
|
[](https://github.com/marcpage/devops-driver/pulls)
|
|
64
|
-
[](https://github.com/marcpage/devops-driver/graphs/contributors)
|
|
65
|
-
[](http://makeapullrequest.com)
|
|
66
69
|
|
|
67
70
|
[](https://github.com/marcpage?tab=followers)
|
|
68
71
|
[](https://github.com/marcpage/devops-driver/watchers)
|
|
@@ -70,3 +73,4 @@ Requires-Dist: keyring==25.0.0
|
|
|
70
73
|
# devops-driver
|
|
71
74
|
|
|
72
75
|
Devops-driver is a set of tools to help streamline developer's experience. It is a collection of tools to help gain insights into various processes.
|
|
76
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
devopsdriver/__init__.py
|
|
5
|
+
devopsdriver/settings.py
|
|
6
|
+
devopsdriver.egg-info/PKG-INFO
|
|
7
|
+
devopsdriver.egg-info/SOURCES.txt
|
|
8
|
+
devopsdriver.egg-info/dependency_links.txt
|
|
9
|
+
devopsdriver.egg-info/requires.txt
|
|
10
|
+
devopsdriver.egg-info/top_level.txt
|
|
11
|
+
devopsdriver/azure/__init__.py
|
|
12
|
+
devopsdriver/azure/clients.py
|
|
13
|
+
devopsdriver/azure/workitem/__init__.py
|
|
14
|
+
devopsdriver/azure/workitem/client.py
|
|
15
|
+
devopsdriver/azure/workitem/wiql.py
|
|
16
|
+
devopsdriver/azure/workitem/workitem.py
|
|
17
|
+
tests/test_azure_clients.py
|
|
18
|
+
tests/test_azure_workitem.py
|
|
19
|
+
tests/test_azure_workitem_client.py
|
|
20
|
+
tests/test_azure_workitem_wiql.py
|
|
21
|
+
tests/test_settings.py
|
|
@@ -8,6 +8,8 @@ requires-python = ">= 3.10"
|
|
|
8
8
|
dependencies = [
|
|
9
9
|
"PyYAML==6.0.1",
|
|
10
10
|
"keyring==25.0.0",
|
|
11
|
+
"setuptools==69.0.2", # neded for azure-devops to use 7.1 API
|
|
12
|
+
"azure-devops==7.1.0b4",
|
|
11
13
|
]
|
|
12
14
|
keywords = ["azure", "devops", "jira", "confluence", "email", "pipelines", "tools"]
|
|
13
15
|
classifiers=[
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
""" test azure client logic """
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
from tempfile import TemporaryDirectory
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from os.path import join
|
|
10
|
+
|
|
11
|
+
from helpers import setup_settings, write
|
|
12
|
+
|
|
13
|
+
from devopsdriver import Azure
|
|
14
|
+
from devopsdriver.azure import clients
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MockConnection: # pylint: disable=too-few-public-methods
|
|
18
|
+
"""Fakes an Azure connection"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, base_url: str, creds: SimpleNamespace):
|
|
21
|
+
self.base_url = base_url
|
|
22
|
+
self.creds = creds
|
|
23
|
+
|
|
24
|
+
class Clients71: # pylint: disable=too-few-public-methods
|
|
25
|
+
"""Fakes a 7.1 clients factory"""
|
|
26
|
+
|
|
27
|
+
def get_work_item_tracking_client(self) -> str:
|
|
28
|
+
"""fakes getting work item client"""
|
|
29
|
+
return "work_item_tracking_client"
|
|
30
|
+
|
|
31
|
+
self.clients_v7_1 = Clients71()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_basic() -> None:
|
|
35
|
+
"""test the basic calling"""
|
|
36
|
+
clients.CONNECTION = MockConnection
|
|
37
|
+
clients.AUTHENTICATION = lambda a, b: SimpleNamespace(empty=a, token=b)
|
|
38
|
+
azure = Azure(None, "token", "https://url.com/project")
|
|
39
|
+
assert (
|
|
40
|
+
azure.connection.base_url == "https://url.com/project"
|
|
41
|
+
), azure.connection.base_url
|
|
42
|
+
assert azure.connection.creds.token == "token", azure.connection.creds.token
|
|
43
|
+
assert azure.connection.creds.empty == "", azure.connection.creds.empty
|
|
44
|
+
assert azure.workitem.client == "work_item_tracking_client"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_settings() -> None:
|
|
48
|
+
"""test the basic calling"""
|
|
49
|
+
clients.CONNECTION = MockConnection
|
|
50
|
+
clients.AUTHENTICATION = lambda a, b: SimpleNamespace(empty=a, token=b)
|
|
51
|
+
azure = Azure({"azure.token": "token", "azure.url": "https://url.com/project"})
|
|
52
|
+
assert (
|
|
53
|
+
azure.connection.base_url == "https://url.com/project"
|
|
54
|
+
), azure.connection.base_url
|
|
55
|
+
assert azure.connection.creds.token == "token", azure.connection.creds.token
|
|
56
|
+
assert azure.connection.creds.empty == "", azure.connection.creds.empty
|
|
57
|
+
assert azure.workitem.client == "work_item_tracking_client"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_mixed_settings() -> None:
|
|
61
|
+
"""test the basic calling"""
|
|
62
|
+
clients.CONNECTION = MockConnection
|
|
63
|
+
clients.AUTHENTICATION = lambda a, b: SimpleNamespace(empty=a, token=b)
|
|
64
|
+
azure = Azure(
|
|
65
|
+
{"azure.token": "token", "azure.url": "https://url.com/project"},
|
|
66
|
+
token="fake token",
|
|
67
|
+
)
|
|
68
|
+
assert (
|
|
69
|
+
azure.connection.base_url == "https://url.com/project"
|
|
70
|
+
), azure.connection.base_url
|
|
71
|
+
assert azure.connection.creds.token == "fake token", azure.connection.creds.token
|
|
72
|
+
assert azure.connection.creds.empty == "", azure.connection.creds.empty
|
|
73
|
+
assert azure.workitem.client == "work_item_tracking_client"
|
|
74
|
+
|
|
75
|
+
azure = Azure(
|
|
76
|
+
{"azure.token": "token", "azure.url": "https://url.com/project"},
|
|
77
|
+
url="https://fake.com/project",
|
|
78
|
+
)
|
|
79
|
+
assert (
|
|
80
|
+
azure.connection.base_url == "https://fake.com/project"
|
|
81
|
+
), azure.connection.base_url
|
|
82
|
+
assert azure.connection.creds.token == "token", azure.connection.creds.token
|
|
83
|
+
assert azure.connection.creds.empty == "", azure.connection.creds.empty
|
|
84
|
+
assert azure.workitem.client == "work_item_tracking_client"
|
|
85
|
+
|
|
86
|
+
azure = Azure(
|
|
87
|
+
{"azure.token": "token", "azure.url": "https://url.com/project"},
|
|
88
|
+
token="fake token",
|
|
89
|
+
url="https://fake.com/project",
|
|
90
|
+
)
|
|
91
|
+
assert (
|
|
92
|
+
azure.connection.base_url == "https://fake.com/project"
|
|
93
|
+
), azure.connection.base_url
|
|
94
|
+
assert azure.connection.creds.token == "fake token", azure.connection.creds.token
|
|
95
|
+
assert azure.connection.creds.empty == "", azure.connection.creds.empty
|
|
96
|
+
assert azure.workitem.client == "work_item_tracking_client"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_load_settings() -> None:
|
|
100
|
+
"""test the basic calling"""
|
|
101
|
+
with TemporaryDirectory() as working_dir:
|
|
102
|
+
base_dir = join(working_dir, "base")
|
|
103
|
+
setup_settings(
|
|
104
|
+
os="Linux",
|
|
105
|
+
shared="test",
|
|
106
|
+
Linux=join(base_dir, "Linux"),
|
|
107
|
+
Darwin=join(base_dir, "macOS"),
|
|
108
|
+
Windows=join(base_dir, "Windows"),
|
|
109
|
+
)
|
|
110
|
+
write(
|
|
111
|
+
join(base_dir, "Linux", "test.yml"),
|
|
112
|
+
azure={"token": "token", "url": "https://url.com/project"},
|
|
113
|
+
)
|
|
114
|
+
clients.CONNECTION = MockConnection
|
|
115
|
+
clients.AUTHENTICATION = lambda a, b: SimpleNamespace(empty=a, token=b)
|
|
116
|
+
|
|
117
|
+
azure = Azure()
|
|
118
|
+
assert (
|
|
119
|
+
azure.connection.base_url == "https://url.com/project"
|
|
120
|
+
), azure.connection.base_url
|
|
121
|
+
assert azure.connection.creds.token == "token", azure.connection.creds.token
|
|
122
|
+
assert azure.connection.creds.empty == "", azure.connection.creds.empty
|
|
123
|
+
assert azure.workitem.client == "work_item_tracking_client"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_not_all_clients() -> None:
|
|
127
|
+
"""test the basic calling"""
|
|
128
|
+
clients.CONNECTION = MockConnection
|
|
129
|
+
clients.AUTHENTICATION = lambda a, b: SimpleNamespace(empty=a, token=b)
|
|
130
|
+
azure = Azure(None, "token", "https://url.com/project", pipeline=True)
|
|
131
|
+
assert (
|
|
132
|
+
azure.connection.base_url == "https://url.com/project"
|
|
133
|
+
), azure.connection.base_url
|
|
134
|
+
assert azure.connection.creds.token == "token", azure.connection.creds.token
|
|
135
|
+
assert azure.connection.creds.empty == "", azure.connection.creds.empty
|
|
136
|
+
assert azure.workitem.client is None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
test_not_all_clients()
|
|
141
|
+
test_load_settings()
|
|
142
|
+
test_mixed_settings()
|
|
143
|
+
test_settings()
|
|
144
|
+
test_basic()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
""" Test work item """
|
|
4
|
+
|
|
5
|
+
from devopsdriver.azure import WorkItem
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MockAzureWorkItem:
|
|
9
|
+
"""mock out work item"""
|
|
10
|
+
|
|
11
|
+
def as_dict(self):
|
|
12
|
+
"""mock out as_dict"""
|
|
13
|
+
return {
|
|
14
|
+
"id": 5,
|
|
15
|
+
"rev": 1,
|
|
16
|
+
"url": "https://dev.azure.com/MyOrg/faf4b2ab-a8b4-4ab8-bca8-6f1f63fe6a91/"
|
|
17
|
+
+ "_apis/wit/workItems/5/revisions/1",
|
|
18
|
+
"fields": {
|
|
19
|
+
"System.WorkItemType": "User Story",
|
|
20
|
+
"System.State": "New",
|
|
21
|
+
"System.Reason": "New",
|
|
22
|
+
"System.CreatedDate": "2023-11-16T03:12:32.94Z",
|
|
23
|
+
"System.CreatedBy": {
|
|
24
|
+
"displayName": "Edna Johnson",
|
|
25
|
+
"url": "https://spsprodcus5.vssps.visualstudio.com/"
|
|
26
|
+
+ "A3eb27a26-75f2-40f9-87dc-cc10e8e565e4/_apis/Identities"
|
|
27
|
+
+ "/45fcf770-0670-69d4-8e48-3ae6e0bf9b5c",
|
|
28
|
+
"_links": {
|
|
29
|
+
"avatar": {
|
|
30
|
+
"href": "https://dev.azure.com/MyOrg/_apis/GraphProfile/"
|
|
31
|
+
+ "MemberAvatars/aad.NDVmY2Y3NzAtMDY3MC03OWQ0LThlNDgtM2FlNmUwYmY5YjVj"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"id": "45fcf770-0670-69d4-8e48-3ae6e0bf9b5c",
|
|
35
|
+
"uniqueName": "edna@company.com",
|
|
36
|
+
"imageUrl": "https://dev.azure.com/MyOrg/_apis/GraphProfile/"
|
|
37
|
+
+ "MemberAvatars/aad.NDVmY2Y3NzAtMDY3MC03OWQ0LThlNDgtM2FlNmUwYmY5YjVj",
|
|
38
|
+
"inactive": True,
|
|
39
|
+
"descriptor": "aad.NDVmY2Y3NzAtMDY3MC03OWQ0LThlNDgtM2FlNmUwYmY5YjVj",
|
|
40
|
+
},
|
|
41
|
+
"System.ChangedDate": "2023-11-16T03:12:32.94Z",
|
|
42
|
+
"System.ChangedBy": {
|
|
43
|
+
"displayName": "Edna Johnson",
|
|
44
|
+
"url": "https://spsprodcus5.vssps.visualstudio.com/"
|
|
45
|
+
+ "A3eb27a26-75f2-40f9-87dc-cc10e8e565e4/_apis/Identities/"
|
|
46
|
+
+ "45fcf770-0670-69d4-8e48-3ae6e0bf9b5c",
|
|
47
|
+
"_links": {
|
|
48
|
+
"avatar": {
|
|
49
|
+
"href": "https://dev.azure.com/MyOrg/_apis/GraphProfile/"
|
|
50
|
+
+ "MemberAvatars/aad.NDVmY2Y3NzAtMDY3MC03OWQ0LThlNDgtM2FlNmUwYmY5YjVj"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"id": "45fcf770-0670-69d4-8e48-3ae6e0bf9b5c",
|
|
54
|
+
"uniqueName": "edna@company.com",
|
|
55
|
+
"imageUrl": "https://dev.azure.com/MyOrg/_apis/GraphProfile/"
|
|
56
|
+
+ "MemberAvatars/aad.NDVmY2Y3NzAtMDY3MC03OWQ0LThlNDgtM2FlNmUwYmY5YjVj",
|
|
57
|
+
"inactive": True,
|
|
58
|
+
"descriptor": "aad.NDVmY2Y3NzAtMDY3MC03OWQ0LThlNDgtM2FlNmUwYmY5YjVj",
|
|
59
|
+
},
|
|
60
|
+
"System.CommentCount": 0,
|
|
61
|
+
"System.TeamProject": "Creative",
|
|
62
|
+
"System.AreaPath": "Creative",
|
|
63
|
+
"System.IterationPath": "Creative\\November 2 2023",
|
|
64
|
+
"System.Title": "test",
|
|
65
|
+
"Microsoft.VSTS.Common.Priority": 2,
|
|
66
|
+
"Microsoft.VSTS.Common.ValueArea": "Business",
|
|
67
|
+
"WEF_FBFB2B85F9CD4A7C9AA907EBB29D5863_Kanban.Column": "To Do",
|
|
68
|
+
"WEF_FBFB2B85F9CD4A7C9AA907EBB29D5863_Kanban.Column.Done": False,
|
|
69
|
+
"System.BoardColumn": "To Do",
|
|
70
|
+
"System.BoardColumnDone": False,
|
|
71
|
+
"Microsoft.VSTS.Common.StateChangeDate": "2023-11-16T03:12:32.94Z",
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_workitem() -> None:
|
|
77
|
+
"""test basic work item"""
|
|
78
|
+
wi = WorkItem(MockAzureWorkItem())
|
|
79
|
+
assert wi.id == 5, wi.id
|
|
80
|
+
assert wi.ID == 5, wi.ID
|
|
81
|
+
assert wi.workitemtype == "User Story", wi.workitemtype
|
|
82
|
+
assert wi.system_workitemtype == "User Story", wi.system_workitemtype
|
|
83
|
+
assert wi.ChangedBy["displayName"] == "Edna Johnson", wi.changedBy
|
|
84
|
+
assert wi.changedby.displayname == "Edna Johnson", wi.changedby.displayname
|
|
85
|
+
assert wi.microsoft_vsts_common_priority == 2, wi.microsoft_vsts_common_priority
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
_ = wi.not_a_field
|
|
89
|
+
|
|
90
|
+
except AttributeError as error:
|
|
91
|
+
assert "not_a_field" in str(error)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
test_workitem()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
""" Module Doc """
|
|
4
|
+
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
from devopsdriver.azure.workitem.client import Client
|
|
8
|
+
from devopsdriver.azure import Wiql, Equal
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MockClient: # pylint: disable=too-few-public-methods
|
|
12
|
+
"""fake an azure work item client, at least what we use"""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.query = None
|
|
16
|
+
|
|
17
|
+
def query_by_wiql(self, wiql, team_context, time_precision, top) -> SimpleNamespace:
|
|
18
|
+
"""mock out the query_by_wiql"""
|
|
19
|
+
self.query = wiql.query
|
|
20
|
+
assert team_context is None, team_context
|
|
21
|
+
assert time_precision is None, time_precision
|
|
22
|
+
assert top is None, top
|
|
23
|
+
return SimpleNamespace(
|
|
24
|
+
work_items=[SimpleNamespace(id=number) for number in range(0, 20)]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def get_revisions(self, wi_id, project, top, skip, expand):
|
|
28
|
+
"""Mock out get_revisions"""
|
|
29
|
+
assert project is None, project
|
|
30
|
+
assert top is None, top
|
|
31
|
+
assert skip is None, skip
|
|
32
|
+
assert expand is None, expand
|
|
33
|
+
assert 0 <= wi_id < 20
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_basic() -> None:
|
|
38
|
+
"""Perform basic test on search and find_ids"""
|
|
39
|
+
client = Client(MockClient())
|
|
40
|
+
ids = client.find_ids(Wiql().select("State").where(Equal("State", "New")))
|
|
41
|
+
assert ids == list(range(0, 20))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_history() -> None:
|
|
45
|
+
"""test history"""
|
|
46
|
+
client = Client(MockClient())
|
|
47
|
+
history = client.history(2)
|
|
48
|
+
assert not history
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_find() -> None:
|
|
52
|
+
"""Tests the find with the devops azure WorkItem"""
|
|
53
|
+
client = Client(MockClient())
|
|
54
|
+
found = client.find(Wiql().select("State").where(Equal("State", "New")))
|
|
55
|
+
assert len(found) == 20
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
test_find()
|
|
60
|
+
test_history()
|
|
61
|
+
test_basic()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
""" Test work item query language """
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
|
|
7
|
+
from devopsdriver.azure import Wiql
|
|
8
|
+
from devopsdriver.azure import Ascending, Descending, Value
|
|
9
|
+
from devopsdriver.azure import IsEmpty, IsNotEmpty, And, Or
|
|
10
|
+
from devopsdriver.azure import GreaterThan, LessThan, Equal, NotEqual
|
|
11
|
+
from devopsdriver.azure import GreaterThanOrEqual, LessThanOrEqual
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_no_params() -> None:
|
|
15
|
+
"""Test empty, default wiql"""
|
|
16
|
+
assert str(Wiql()) == "SELECT [System.Id] FROM workitems", str(Wiql())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_expressions() -> None:
|
|
20
|
+
"""Test much of the expression"""
|
|
21
|
+
start = date(2024, 6, 30)
|
|
22
|
+
end = datetime(2025, 1, 1, 18, 30, 15)
|
|
23
|
+
builder = (
|
|
24
|
+
Wiql()
|
|
25
|
+
.select("State", "Id")
|
|
26
|
+
.where(
|
|
27
|
+
And(
|
|
28
|
+
Equal("State", "New"),
|
|
29
|
+
IsEmpty("Title"),
|
|
30
|
+
IsNotEmpty("Priority"),
|
|
31
|
+
GreaterThan("CreatedDate", start),
|
|
32
|
+
LessThan("CreatedDate", end),
|
|
33
|
+
Or(
|
|
34
|
+
IsEmpty("RootCause"),
|
|
35
|
+
NotEqual("Priority", 1),
|
|
36
|
+
LessThan("Rank", 5.0),
|
|
37
|
+
LessThanOrEqual("time", 4.0),
|
|
38
|
+
GreaterThanOrEqual("BusinessValue", 4),
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
.order_by(Descending("Priority"), Ascending("CreatedDate"))
|
|
43
|
+
.asof(start)
|
|
44
|
+
)
|
|
45
|
+
expected = (
|
|
46
|
+
"""SELECT [System.State], [System.Id] FROM workitems """
|
|
47
|
+
+ """WHERE [System.State] = "New" AND [System.Title] IS EMPTY """
|
|
48
|
+
+ """AND [Microsoft.VSTS.Common.Priority] IS NOT EMPTY """
|
|
49
|
+
+ """AND [System.CreatedDate] > 2024-06-30 """
|
|
50
|
+
+ """AND [System.CreatedDate] < 2025-01-01 18:30:15 AND [RootCause] IS EMPTY """
|
|
51
|
+
+ """OR [Microsoft.VSTS.Common.Priority] <> 1 OR [Rank] < 5.000 """
|
|
52
|
+
+ """OR [time] <= 4.000 OR [BusinessValue] >= 4 """
|
|
53
|
+
+ """ORDER BY [Microsoft.VSTS.Common.Priority] DESC, [System.CreatedDate] ASC """
|
|
54
|
+
+ """ASOF 2024-06-30"""
|
|
55
|
+
)
|
|
56
|
+
assert str(builder) == expected, str(builder)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_invalid_value_type() -> None:
|
|
60
|
+
"""tests assertion that field has known types"""
|
|
61
|
+
try:
|
|
62
|
+
assert str(Value(test_no_params)) is None, str(Value(test_no_params))
|
|
63
|
+
|
|
64
|
+
except AssertionError:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
test_invalid_value_type()
|
|
70
|
+
test_expressions()
|
|
71
|
+
test_no_params()
|
|
@@ -3,54 +3,15 @@
|
|
|
3
3
|
""" Tests Settings class """
|
|
4
4
|
|
|
5
5
|
from tempfile import TemporaryDirectory
|
|
6
|
-
from os.path import join
|
|
7
|
-
from os import makedirs
|
|
8
|
-
from json import dump
|
|
6
|
+
from os.path import join
|
|
9
7
|
from string import ascii_lowercase
|
|
10
8
|
from itertools import product
|
|
11
9
|
|
|
12
|
-
from
|
|
10
|
+
from helpers import setup_settings, ensure, write
|
|
13
11
|
|
|
14
12
|
import devopsdriver.settings as settings
|
|
15
13
|
|
|
16
14
|
|
|
17
|
-
def __setup_settings(os: str = "Linux", shared: str = "test", **pref_dirs) -> None:
|
|
18
|
-
settings.ENVIRON = {}
|
|
19
|
-
settings.ARGV = []
|
|
20
|
-
settings.SYSTEM = lambda: os
|
|
21
|
-
settings.SHARED = shared
|
|
22
|
-
settings.PRINT = lambda s: s
|
|
23
|
-
settings.GET_PASSWORD = lambda s, n: f"{s}:{n}"
|
|
24
|
-
settings.GET_PASS = lambda p: p
|
|
25
|
-
settings.SET_PASSWORD = lambda s, n, p: f"{s} {n} {p}"
|
|
26
|
-
# settings.MAKEDIRS = lambda p: p
|
|
27
|
-
# settings.Settings.FORMATS = None
|
|
28
|
-
settings.Settings.PREF_DIR = pref_dirs
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def ensure(directory: str) -> str:
|
|
32
|
-
"""Ensures that a directory exists before using
|
|
33
|
-
|
|
34
|
-
Args:
|
|
35
|
-
directory (str): The directory to create
|
|
36
|
-
|
|
37
|
-
Returns:
|
|
38
|
-
str: The directory that now exists
|
|
39
|
-
"""
|
|
40
|
-
makedirs(directory, exist_ok=True)
|
|
41
|
-
return directory
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def __write(path: str, **options) -> None:
|
|
45
|
-
ensure(dirname(path))
|
|
46
|
-
|
|
47
|
-
with open(path, "w", encoding="utf-8") as settings_file:
|
|
48
|
-
if splitext(path)[1] == ".json":
|
|
49
|
-
dump(options, settings_file)
|
|
50
|
-
else:
|
|
51
|
-
safe_dump(options, settings_file)
|
|
52
|
-
|
|
53
|
-
|
|
54
15
|
def __setup_files(directory: str, dir1: str, dir2: str) -> None:
|
|
55
16
|
"""
|
|
56
17
|
Priorities:
|
|
@@ -86,7 +47,7 @@ def __setup_files(directory: str, dir1: str, dir2: str) -> None:
|
|
|
86
47
|
|
|
87
48
|
for name in ("test", "main"):
|
|
88
49
|
for letter, os_dir in (("l", lin_dir), ("w", win_dir), ("m", mac_dir)):
|
|
89
|
-
|
|
50
|
+
write(
|
|
90
51
|
join(os_dir, "test.json"),
|
|
91
52
|
**{l: f"{name[0]}{letter}{l}" for l in letters},
|
|
92
53
|
dp={l: f"{name[0]}{letter}{l}" for l in letters},
|
|
@@ -97,7 +58,7 @@ def __setup_files(directory: str, dir1: str, dir2: str) -> None:
|
|
|
97
58
|
for directory, name, ext in product(
|
|
98
59
|
(dir2, dir1, directory), ("test", "main"), (".json", ".yaml", ".yml")
|
|
99
60
|
):
|
|
100
|
-
|
|
61
|
+
write(
|
|
101
62
|
join(directory, name + ext),
|
|
102
63
|
**{l: f"{directory[-1]}{ext[2]}{name[0]}{letter}{l}" for l in letters},
|
|
103
64
|
dp={l: f"{directory[-1]}{ext[2]}{name[0]}{letter}{l}" for l in letters},
|
|
@@ -111,7 +72,7 @@ def test_basic():
|
|
|
111
72
|
base_dir = join(working_dir, "base")
|
|
112
73
|
|
|
113
74
|
for os in ("Linux", "Darwin", "Windows", "Unknown"):
|
|
114
|
-
|
|
75
|
+
setup_settings(
|
|
115
76
|
os=os,
|
|
116
77
|
shared="test",
|
|
117
78
|
Linux=join(base_dir, "Linux"),
|
|
@@ -206,14 +167,14 @@ def test_cli_env_in_yaml():
|
|
|
206
167
|
"""test setting cli and env lookups in the yaml itself"""
|
|
207
168
|
with TemporaryDirectory() as working_dir:
|
|
208
169
|
base_dir = join(working_dir, "base")
|
|
209
|
-
|
|
170
|
+
setup_settings(
|
|
210
171
|
os="Linux",
|
|
211
172
|
shared="test",
|
|
212
173
|
Linux=join(base_dir, "Linux"),
|
|
213
174
|
Darwin=join(base_dir, "macOS"),
|
|
214
175
|
Windows=join(base_dir, "Windows"),
|
|
215
176
|
)
|
|
216
|
-
|
|
177
|
+
write(
|
|
217
178
|
join(base_dir, "main.yml"),
|
|
218
179
|
env={"aa": "alpha", "yy": "yota"},
|
|
219
180
|
cli={"bb": "--beta"},
|
|
@@ -221,7 +182,7 @@ def test_cli_env_in_yaml():
|
|
|
221
182
|
aa="main aa",
|
|
222
183
|
bb="main bb",
|
|
223
184
|
)
|
|
224
|
-
|
|
185
|
+
write(
|
|
225
186
|
join(base_dir, "test.yml"),
|
|
226
187
|
env={"zz": "zeta"},
|
|
227
188
|
cli={"dd": "--delta"},
|
|
@@ -259,14 +220,14 @@ def test_environ_values():
|
|
|
259
220
|
"""test environment variable substitution"""
|
|
260
221
|
with TemporaryDirectory() as working_dir:
|
|
261
222
|
base_dir = join(working_dir, "base")
|
|
262
|
-
|
|
223
|
+
setup_settings(
|
|
263
224
|
os="Linux",
|
|
264
225
|
shared="test",
|
|
265
226
|
Linux=join(base_dir, "Linux"),
|
|
266
227
|
Darwin=join(base_dir, "macOS"),
|
|
267
228
|
Windows=join(base_dir, "Windows"),
|
|
268
229
|
)
|
|
269
|
-
|
|
230
|
+
write(
|
|
270
231
|
join(base_dir, "main.yml"),
|
|
271
232
|
output="${home}/reports",
|
|
272
233
|
settings="${appDir}/settings.json",
|
|
@@ -289,9 +250,9 @@ def test_environ_values():
|
|
|
289
250
|
def test_main():
|
|
290
251
|
"""test the main entry point"""
|
|
291
252
|
with TemporaryDirectory() as working_dir:
|
|
292
|
-
|
|
253
|
+
setup_settings(shared="test", Linux=join(working_dir, "Linux"))
|
|
293
254
|
settings.ARGV = ["ignore", "test"]
|
|
294
|
-
|
|
255
|
+
write(join(working_dir, "Linux", "test.yml"), test=3)
|
|
295
256
|
settings.main()
|
|
296
257
|
|
|
297
258
|
|
|
@@ -300,7 +261,7 @@ def test_secret():
|
|
|
300
261
|
with TemporaryDirectory() as working_dir:
|
|
301
262
|
base_dir = join(working_dir, "base")
|
|
302
263
|
passwords = {"system": {"john": "setec astronomy"}}
|
|
303
|
-
|
|
264
|
+
setup_settings(
|
|
304
265
|
os="Linux",
|
|
305
266
|
shared="test",
|
|
306
267
|
Linux=join(base_dir, "Linux"),
|
|
@@ -308,7 +269,7 @@ def test_secret():
|
|
|
308
269
|
Windows=join(base_dir, "Windows"),
|
|
309
270
|
)
|
|
310
271
|
settings.GET_PASSWORD = lambda s, e: passwords.get(s, {}).get(e, None)
|
|
311
|
-
|
|
272
|
+
write(join(base_dir, "main.yml"), password="main")
|
|
312
273
|
opts = settings.Settings(join(base_dir, "main.py")).key(
|
|
313
274
|
"password", "system/john"
|
|
314
275
|
)
|
|
@@ -319,17 +280,21 @@ def test_main_set_secret():
|
|
|
319
280
|
"""test the main entry point when settings keychain secrets"""
|
|
320
281
|
|
|
321
282
|
def set_password(s, n, p):
|
|
322
|
-
assert
|
|
283
|
+
assert (
|
|
284
|
+
s in ("azure", "jira") and n == "token" and p == "setec astronomy"
|
|
285
|
+
), f"{s} {n} {p}"
|
|
323
286
|
|
|
324
287
|
with TemporaryDirectory() as working_dir:
|
|
325
|
-
|
|
326
|
-
settings.ARGV = ["ignore", "--
|
|
327
|
-
settings.GET_PASSWORD = lambda s, n:
|
|
288
|
+
setup_settings(shared="test", Linux=join(working_dir, "Linux"))
|
|
289
|
+
settings.ARGV = ["ignore", "--secrets"]
|
|
290
|
+
settings.GET_PASSWORD = lambda s, n: (
|
|
291
|
+
"password" if f"{s}/{n}" == "azure/token" else None
|
|
292
|
+
)
|
|
328
293
|
settings.GET_PASS = lambda p: "setec astronomy"
|
|
329
294
|
settings.SET_PASSWORD = set_password
|
|
330
|
-
|
|
295
|
+
write(
|
|
331
296
|
join(working_dir, "Linux", "test.yml"),
|
|
332
|
-
secrets={"azure.token": "azure/token"},
|
|
297
|
+
secrets={"azure.token": "azure/token", "jira.token": "jira/token"},
|
|
333
298
|
)
|
|
334
299
|
settings.main()
|
|
335
300
|
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
LICENSE
|
|
2
|
-
README.md
|
|
3
|
-
pyproject.toml
|
|
4
|
-
devopsdriver/__init__.py
|
|
5
|
-
devopsdriver/settings.py
|
|
6
|
-
devopsdriver.egg-info/PKG-INFO
|
|
7
|
-
devopsdriver.egg-info/SOURCES.txt
|
|
8
|
-
devopsdriver.egg-info/dependency_links.txt
|
|
9
|
-
devopsdriver.egg-info/requires.txt
|
|
10
|
-
devopsdriver.egg-info/top_level.txt
|
|
11
|
-
tests/test_settings.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|