Merge ~mertkirpici/juju-lint:lp/1987951 into juju-lint:master
- Git
- lp:~mertkirpici/juju-lint
- lp/1987951
- Merge into master
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Eric Chen | ||||
Approved revision: | ef16249f7f6c281351f5cc8d9472c881d95133a5 | ||||
Merged at revision: | d89eef7b64063e543bffb1e292152a9118e1dfe9 | ||||
Proposed branch: | ~mertkirpici/juju-lint:lp/1987951 | ||||
Merge into: | juju-lint:master | ||||
Diff against target: |
306 lines (+158/-14) 7 files modified
jujulint/cli.py (+24/-5) jujulint/config.py (+13/-7) jujulint/util.py (+14/-0) tests/functional/conftest.py (+21/-0) tests/functional/test_jujulint.py (+47/-0) tests/unit/conftest.py (+1/-0) tests/unit/test_cli.py (+38/-2) |
||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Gabriel Cocenza | Approve | ||
Martin Kalcok (community) | Approve | ||
Eric Chen | Approve | ||
BootStack Reviewers | Pending | ||
Review via email: mp+429025@code.launchpad.net |
Commit message
Close LP #1987951
Description of the change
config: make --dump-state boolean
The command line argument --dump-state is in fact used as a boolean flag
however it expects a dummy argument, causing confusion. With this change
it only using it will be enough. i.e.:
$ juju-lint --dump-state -d outdir -c ...
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Mert Kirpici (mertkirpici) wrote : | # |
Lets wait until the MP[0] for the functional tests are merged for this. I want to add a functional test for the --dump-state as well.
[0] https:/
Gabriel Cocenza (gabrielcocenza) wrote : | # |
Thanks for filling the bug and sending this patch.
Looking at the source code I think we should get rid off this dump-state flag. IMO it just makes harder to use the CLI. If the user passes the output dir, why should we force use another flag to dump it?
Moreover, I see three "big" problems that we should think about it:
1 - help of "output-dir" in the cli says "The folder to use when saving gathered cloud data and lint reports." I've checked and we are not writing lint reports. The dump contains the content of the following juju comands:
- juju controllers
- juju status
- juju export-bundle
2 - the command juju controllers shows sensible information (e.g: ca-cert of the controller). I see this as a security issue and I think it's not a good standard behavior.
3 - Finally, we are not dealing with wrong path from the user. Passing an invalid path raises FileNotFoundError.
Mert Kirpici (mertkirpici) wrote : | # |
Rebased the branch and pushed an update to partially address Gabriel's comments.
A small summary of changes:
- I did get rid of the `--output-dir` flag rather than the `--dump-state` since it felt more intuitive for me to use it like "dump the state to DIR", rather than "output to DIR" which begs the question "What output?"
- Now we are checking the dump directory for permissions and existence before running any sort of linting.
- New functests for this feature.
I am thinking of filing bugs and addressing the issues #1(missing lint results in dumps) and #2(sensitive info in dumps) in separate MPs since they are quite serious bugs within themselves.
Last but not least, here is the functest run after this change:
https:/
Gabriel Cocenza (gabrielcocenza) wrote : | # |
The patch LGTM, but unit tests are failing because of the changes and because now we need 100% line coverage.
Regarding the bugs, please fill the first one. The second one after talking with Martin, it's not that bad because it's the public certificate of the controller.
Thanks!
Mert Kirpici (mertkirpici) wrote : | # |
Hey again, so first of all I did go ahead and file LP#1989673 for the first item you mentioned.
https:/
After some thought I decided to deprecate the `--dump-state` rather than directly removing it, so this newly pushed update is mainly dealing with that. It:
- introduces a new argparse.Action subclass for deprecation
- marks `--dump-state` as nargs="*" therefore no matter with how many arguments it is called, it will be valid(and no-op, just log a warning message)
- fixes breaking unittests and adds a test to ensure %100 coverage
- re-renames some tests and function names for consistency's sake
Here is the `make test` output:
https:/
I do have one request though. If you think this is good to go, could you let me know before merging so that I can squash the commits and add explanation for the changes in the commit message to clear up the intention behind this change?
Martin Kalcok (martin-kalcok) wrote : | # |
Just one note and I think this is ready for merge. Please include the warning about sensitive data being dumped, that was previously shown in the "--dump-state" help message, in the "--output-dir".
Eric Chen (eric-chen) wrote : | # |
Please Gabriel Angelo Sgarbi Cocenza confirm this. And please mert provides the log of lint/unit/func if jenking bot is not ready.
Any MR should be
1) 0 "Need Fixing"
2) 2+ approval
3) approval of jenkins bot or provide log of lint/unit/func manually
Gabriel Cocenza (gabrielcocenza) wrote : | # |
LGTM, but before merging please see my comments and please provide the logs of the tests.
Mert Kirpici (mertkirpici) wrote : | # |
updated one last time. changes:
- squashed all commits, re-written the commit message, rebased onto current master
- added a forgotten @pytest.fixture to fix the unit tests
- I really liked Gabriel's idea to parametrize the bad output directory tests. so did that.
- Following review comments from Martin and Gabriel, added the warning about sensitive info into the help string and removed that part that mentions lint reports being dumped. Lint reports are not dumped currently, I'd already filed a LP bug about this.
`make test`:
https:/
all green, ready to land this change.
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote : | # |
Change successfully merged at revision d89eef7b64063e5
Preview Diff
1 | diff --git a/jujulint/cli.py b/jujulint/cli.py | |||
2 | index 2ea188a..ac85b2b 100755 | |||
3 | --- a/jujulint/cli.py | |||
4 | +++ b/jujulint/cli.py | |||
5 | @@ -17,9 +17,11 @@ | |||
6 | 17 | # You should have received a copy of the GNU General Public License | 17 | # You should have received a copy of the GNU General Public License |
7 | 18 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | 18 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
8 | 19 | """Main entrypoint for the juju-lint CLI.""" | 19 | """Main entrypoint for the juju-lint CLI.""" |
9 | 20 | import errno | ||
10 | 20 | import logging | 21 | import logging |
11 | 21 | import os.path | 22 | import os.path |
12 | 22 | import sys | 23 | import sys |
13 | 24 | import tempfile | ||
14 | 23 | 25 | ||
15 | 24 | import pkg_resources | 26 | import pkg_resources |
16 | 25 | import yaml | 27 | import yaml |
17 | @@ -127,6 +129,7 @@ class Cli: | |||
18 | 127 | 129 | ||
19 | 128 | def audit_all(self): | 130 | def audit_all(self): |
20 | 129 | """Iterate over clouds and run audit.""" | 131 | """Iterate over clouds and run audit.""" |
21 | 132 | self._check_output_folder() | ||
22 | 130 | self.logger.debug("Starting audit") | 133 | self.logger.debug("Starting audit") |
23 | 131 | for cloud_name in self.config["clouds"].get(): | 134 | for cloud_name in self.config["clouds"].get(): |
24 | 132 | self.audit(cloud_name) | 135 | self.audit(cloud_name) |
25 | @@ -178,11 +181,27 @@ class Cli: | |||
26 | 178 | 181 | ||
27 | 179 | def write_yaml(self, data, file_name): | 182 | def write_yaml(self, data, file_name): |
28 | 180 | """Write collected information to YAML.""" | 183 | """Write collected information to YAML.""" |
34 | 181 | if "dump" in self.config["output"]: | 184 | folder_name = self.config["output"]["folder"].get() |
35 | 182 | if self.config["output"]["dump"]: | 185 | if folder_name: |
36 | 183 | folder_name = self.config["output"]["folder"].get() | 186 | file_handle = open("{}/{}".format(folder_name, file_name), "w") |
37 | 184 | file_handle = open("{}/{}".format(folder_name, file_name), "w") | 187 | yaml.dump(data, file_handle) |
38 | 185 | yaml.dump(data, file_handle) | 188 | |
39 | 189 | def _check_output_folder(self): | ||
40 | 190 | """Check the output folder for permission and existence.""" | ||
41 | 191 | outdir = self.config["output"]["folder"].get() | ||
42 | 192 | if outdir: | ||
43 | 193 | try: | ||
44 | 194 | with tempfile.TemporaryFile(dir=outdir): | ||
45 | 195 | pass # pragma: no cover | ||
46 | 196 | except FileNotFoundError as err: | ||
47 | 197 | err.filename = outdir | ||
48 | 198 | Logger.fubar(msg=str(err), exit_code=errno.ENOENT) | ||
49 | 199 | except PermissionError as err: | ||
50 | 200 | err.filename = outdir | ||
51 | 201 | Logger.fubar(msg=str(err), exit_code=errno.EACCES) | ||
52 | 202 | except Exception as err: | ||
53 | 203 | err.filename = outdir | ||
54 | 204 | Logger.fubar(msg=str(err)) | ||
55 | 186 | 205 | ||
56 | 187 | 206 | ||
57 | 188 | def main(): | 207 | def main(): |
58 | diff --git a/jujulint/config.py b/jujulint/config.py | |||
59 | index a4822ee..45c5618 100644 | |||
60 | --- a/jujulint/config.py | |||
61 | +++ b/jujulint/config.py | |||
62 | @@ -22,6 +22,8 @@ from argparse import ArgumentParser | |||
63 | 22 | 22 | ||
64 | 23 | from confuse import Configuration | 23 | from confuse import Configuration |
65 | 24 | 24 | ||
66 | 25 | from jujulint.util import DeprecateAction | ||
67 | 26 | |||
68 | 25 | 27 | ||
69 | 26 | class Config(Configuration): | 28 | class Config(Configuration): |
70 | 27 | """Helper class for holding parsed config, extending confuse's BaseConfiguraion class.""" | 29 | """Helper class for holding parsed config, extending confuse's BaseConfiguraion class.""" |
71 | @@ -44,18 +46,22 @@ class Config(Configuration): | |||
72 | 44 | "-d", | 46 | "-d", |
73 | 45 | "--output-dir", | 47 | "--output-dir", |
74 | 46 | type=str, | 48 | type=str, |
78 | 47 | default="output", | 49 | default="", |
79 | 48 | nargs="?", | 50 | metavar="DIR", |
80 | 49 | help="The folder to use when saving gathered cloud data and lint reports.", | 51 | help=( |
81 | 52 | "Dump gathered cloud state data into %(metavar)s. " | ||
82 | 53 | "Note that %(metavar)s must exist and be writable by the user. " | ||
83 | 54 | "Use with caution, as dumps will contain sensitve data. " | ||
84 | 55 | "This feature is disabled by default." | ||
85 | 56 | ), | ||
86 | 50 | dest="output.folder", | 57 | dest="output.folder", |
87 | 51 | ) | 58 | ) |
88 | 52 | self.parser.add_argument( | 59 | self.parser.add_argument( |
89 | 53 | "--dump-state", | 60 | "--dump-state", |
90 | 54 | type=str, | 61 | type=str, |
95 | 55 | help=( | 62 | nargs="*", |
96 | 56 | "Optionally, dump cloud state as YAML into --output-dir." | 63 | action=DeprecateAction, |
97 | 57 | "Use with caution, as dumps will contain sensitve data." | 64 | help="DEPRECATED. See --output-dir for the current behavior", |
94 | 58 | ), | ||
98 | 59 | dest="output.dump", | 65 | dest="output.dump", |
99 | 60 | ) | 66 | ) |
100 | 61 | self.parser.add_argument( | 67 | self.parser.add_argument( |
101 | diff --git a/jujulint/util.py b/jujulint/util.py | |||
102 | index 283fa4b..3b24419 100644 | |||
103 | --- a/jujulint/util.py | |||
104 | +++ b/jujulint/util.py | |||
105 | @@ -18,8 +18,11 @@ | |||
106 | 18 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | 18 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
107 | 19 | """Utility library for all helpful functions this project uses.""" | 19 | """Utility library for all helpful functions this project uses.""" |
108 | 20 | 20 | ||
109 | 21 | import argparse | ||
110 | 21 | import re | 22 | import re |
111 | 22 | 23 | ||
112 | 24 | from jujulint.logging import Logger | ||
113 | 25 | |||
114 | 23 | 26 | ||
115 | 24 | class InvalidCharmNameError(Exception): | 27 | class InvalidCharmNameError(Exception): |
116 | 25 | """Represents an invalid charm name being processed.""" | 28 | """Represents an invalid charm name being processed.""" |
117 | @@ -82,3 +85,14 @@ def extract_charm_name(charm): | |||
118 | 82 | if not match: | 85 | if not match: |
119 | 83 | raise InvalidCharmNameError("charm name '{}' is invalid".format(charm)) | 86 | raise InvalidCharmNameError("charm name '{}' is invalid".format(charm)) |
120 | 84 | return match.group(1) | 87 | return match.group(1) |
121 | 88 | |||
122 | 89 | |||
123 | 90 | class DeprecateAction(argparse.Action): # pragma: no cover | ||
124 | 91 | """Custom deprecation action to be used with ArgumentParser.""" | ||
125 | 92 | |||
126 | 93 | def __call__(self, parser, namespace, values, option_string=None): | ||
127 | 94 | """Print a deprecation warning and remove the attribute from the namespace.""" | ||
128 | 95 | Logger().warn( | ||
129 | 96 | "The argument {} is deprecated and will be ignored. ".format(option_string) | ||
130 | 97 | ) | ||
131 | 98 | delattr(namespace, self.dest) | ||
132 | diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py | |||
133 | index d26b318..7c215c1 100644 | |||
134 | --- a/tests/functional/conftest.py | |||
135 | +++ b/tests/functional/conftest.py | |||
136 | @@ -2,7 +2,9 @@ | |||
137 | 2 | import logging | 2 | import logging |
138 | 3 | import os | 3 | import os |
139 | 4 | import shutil | 4 | import shutil |
140 | 5 | from pathlib import Path | ||
141 | 5 | from subprocess import check_call, check_output | 6 | from subprocess import check_call, check_output |
142 | 7 | from tempfile import TemporaryDirectory | ||
143 | 6 | from textwrap import dedent | 8 | from textwrap import dedent |
144 | 7 | 9 | ||
145 | 8 | import pytest | 10 | import pytest |
146 | @@ -127,3 +129,22 @@ def local_cloud(): | |||
147 | 127 | if backup: | 129 | if backup: |
148 | 128 | logging.info("Restoring backup") | 130 | logging.info("Restoring backup") |
149 | 129 | shutil.move(local_config_dir + ".bak", local_config_dir) | 131 | shutil.move(local_config_dir + ".bak", local_config_dir) |
150 | 132 | |||
151 | 133 | |||
152 | 134 | @pytest.fixture | ||
153 | 135 | def non_existent_directory(): | ||
154 | 136 | """Return a non-existent directory.""" | ||
155 | 137 | dir = Path("/a/path/that/does/not/exist") | ||
156 | 138 | assert not dir.exists() | ||
157 | 139 | return dir | ||
158 | 140 | |||
159 | 141 | |||
160 | 142 | @pytest.fixture | ||
161 | 143 | def non_writable_directory(): | ||
162 | 144 | """Return a non-writable directory.""" | ||
163 | 145 | dir = TemporaryDirectory() | ||
164 | 146 | os.chmod(dir.name, mode=0o555) | ||
165 | 147 | |||
166 | 148 | yield dir.name | ||
167 | 149 | |||
168 | 150 | dir.cleanup() | ||
169 | diff --git a/tests/functional/test_jujulint.py b/tests/functional/test_jujulint.py | |||
170 | index e15e9b6..9d908f1 100644 | |||
171 | --- a/tests/functional/test_jujulint.py | |||
172 | +++ b/tests/functional/test_jujulint.py | |||
173 | @@ -47,3 +47,50 @@ async def test_audit_local_cloud(ops_test, local_cloud, rules_file): | |||
174 | 47 | f"controller {ops_test.controller_name}, model {ops_test.model_name}" in stderr | 47 | f"controller {ops_test.controller_name}, model {ops_test.model_name}" in stderr |
175 | 48 | ) | 48 | ) |
176 | 49 | assert returncode == 0 | 49 | assert returncode == 0 |
177 | 50 | |||
178 | 51 | |||
179 | 52 | @pytest.mark.cloud | ||
180 | 53 | async def test_output_folder(ops_test, local_cloud, rules_file, tmp_path): | ||
181 | 54 | """Test juju-lint state output to folder.""" | ||
182 | 55 | all_data_yaml = tmp_path / "all-data.yaml" | ||
183 | 56 | cloudstate_yaml = tmp_path / f"{local_cloud}-state.yaml" | ||
184 | 57 | assert not all_data_yaml.exists() and not cloudstate_yaml.exists() | ||
185 | 58 | |||
186 | 59 | await ops_test.model.wait_for_idle() | ||
187 | 60 | returncode, _, stderr = await ops_test.run( | ||
188 | 61 | *f"juju-lint -d {tmp_path} -c {rules_file}".split() | ||
189 | 62 | ) | ||
190 | 63 | |||
191 | 64 | assert ( | ||
192 | 65 | f"[{local_cloud}] Linting model information for {socket.getfqdn()}, " | ||
193 | 66 | f"controller {ops_test.controller_name}, model {ops_test.model_name}" in stderr | ||
194 | 67 | ) | ||
195 | 68 | assert returncode == 0 | ||
196 | 69 | assert all_data_yaml.exists() and cloudstate_yaml.exists() | ||
197 | 70 | |||
198 | 71 | |||
199 | 72 | @pytest.mark.parametrize( | ||
200 | 73 | "bad_output_folder, expected_error", | ||
201 | 74 | [ | ||
202 | 75 | ("non_existent_directory", "No such file or directory"), | ||
203 | 76 | ("non_writable_directory", "Permission denied"), | ||
204 | 77 | ], | ||
205 | 78 | ) | ||
206 | 79 | @pytest.mark.cloud | ||
207 | 80 | async def test_bad_output_folder_error( | ||
208 | 81 | ops_test, local_cloud, rules_file, bad_output_folder, expected_error, request | ||
209 | 82 | ): | ||
210 | 83 | """Test juju-lint fails gracefully for bad output folder values.""" | ||
211 | 84 | output_folder = request.getfixturevalue(bad_output_folder) | ||
212 | 85 | |||
213 | 86 | await ops_test.model.wait_for_idle() | ||
214 | 87 | returncode, _, stderr = await ops_test.run( | ||
215 | 88 | *f"juju-lint -d {output_folder} -c {rules_file}".split() | ||
216 | 89 | ) | ||
217 | 90 | assert returncode != 0 | ||
218 | 91 | assert ( | ||
219 | 92 | f"[{local_cloud}] Linting model information for {socket.getfqdn()}, " | ||
220 | 93 | f"controller {ops_test.controller_name}, model {ops_test.model_name}" | ||
221 | 94 | not in stderr | ||
222 | 95 | ) | ||
223 | 96 | assert expected_error in stderr | ||
224 | diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py | |||
225 | index 4d3d3ee..00500ea 100644 | |||
226 | --- a/tests/unit/conftest.py | |||
227 | +++ b/tests/unit/conftest.py | |||
228 | @@ -239,6 +239,7 @@ def rules_files(): | |||
229 | 239 | ] | 239 | ] |
230 | 240 | 240 | ||
231 | 241 | 241 | ||
232 | 242 | @pytest.fixture | ||
233 | 242 | def juju_status_relation(): | 243 | def juju_status_relation(): |
234 | 243 | """Representation of juju status input to test relations checks.""" | 244 | """Representation of juju status input to test relations checks.""" |
235 | 244 | return { | 245 | return { |
236 | diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py | |||
237 | index ed63f67..81d8934 100644 | |||
238 | --- a/tests/unit/test_cli.py | |||
239 | +++ b/tests/unit/test_cli.py | |||
240 | @@ -1,5 +1,6 @@ | |||
241 | 1 | #!/usr/bin/python3 | 1 | #!/usr/bin/python3 |
242 | 2 | """Test the CLI.""" | 2 | """Test the CLI.""" |
243 | 3 | import errno | ||
244 | 3 | from logging import WARN | 4 | from logging import WARN |
245 | 4 | from unittest.mock import MagicMock, call | 5 | from unittest.mock import MagicMock, call |
246 | 5 | 6 | ||
247 | @@ -254,7 +255,11 @@ def test_cli_audit_all(cli_instance, mocker): | |||
248 | 254 | clouds = MagicMock() | 255 | clouds = MagicMock() |
249 | 255 | clouds.get.return_value = clouds_value | 256 | clouds.get.return_value = clouds_value |
250 | 256 | 257 | ||
252 | 257 | config_data = {"clouds": clouds} | 258 | folder_value = "" |
253 | 259 | folder = MagicMock() | ||
254 | 260 | folder.get.return_value = folder_value | ||
255 | 261 | |||
256 | 262 | config_data = {"clouds": clouds, "output": {"folder": folder}} | ||
257 | 258 | config = MagicMock() | 263 | config = MagicMock() |
258 | 259 | config.__getitem__.side_effect = config_data.__getitem__ | 264 | config.__getitem__.side_effect = config_data.__getitem__ |
259 | 260 | 265 | ||
260 | @@ -336,7 +341,7 @@ def test_cli_write_yaml(cli_instance, mocker): | |||
261 | 336 | opened_file = MagicMock() | 341 | opened_file = MagicMock() |
262 | 337 | mock_open = mocker.patch("builtins.open", return_value=opened_file) | 342 | mock_open = mocker.patch("builtins.open", return_value=opened_file) |
263 | 338 | 343 | ||
265 | 339 | config = {"output": {"dump": True, "folder": output_folder}} | 344 | config = {"output": {"folder": output_folder}} |
266 | 340 | 345 | ||
267 | 341 | cli_instance.config = config | 346 | cli_instance.config = config |
268 | 342 | cli_instance.write_yaml(data, file_name) | 347 | cli_instance.write_yaml(data, file_name) |
269 | @@ -347,6 +352,37 @@ def test_cli_write_yaml(cli_instance, mocker): | |||
270 | 347 | yaml_mock.dump.assert_called_once_with(data, opened_file) | 352 | yaml_mock.dump.assert_called_once_with(data, opened_file) |
271 | 348 | 353 | ||
272 | 349 | 354 | ||
273 | 355 | def test_check_output_folder(cli_instance, mocker): | ||
274 | 356 | """Test _check_output_folder() method from Cli class.""" | ||
275 | 357 | folder_value = "/a/non/empty/path/string" | ||
276 | 358 | folder = MagicMock() | ||
277 | 359 | folder.get.return_value = folder_value | ||
278 | 360 | |||
279 | 361 | config = {"output": {"folder": folder}} | ||
280 | 362 | cli_instance.config = config | ||
281 | 363 | |||
282 | 364 | mock_temporary_file = mocker.patch("tempfile.TemporaryFile") | ||
283 | 365 | mock_sys_exit = mocker.patch("sys.exit") | ||
284 | 366 | |||
285 | 367 | mock_temporary_file.return_value.__enter__.side_effect = FileNotFoundError() | ||
286 | 368 | cli_instance._check_output_folder() | ||
287 | 369 | mock_sys_exit.assert_called_once_with(errno.ENOENT) | ||
288 | 370 | |||
289 | 371 | mock_temporary_file.reset_mock(return_value=True) | ||
290 | 372 | mock_sys_exit.reset_mock() | ||
291 | 373 | |||
292 | 374 | mock_temporary_file.return_value.__enter__.side_effect = PermissionError() | ||
293 | 375 | cli_instance._check_output_folder() | ||
294 | 376 | mock_sys_exit.assert_called_once_with(errno.EACCES) | ||
295 | 377 | |||
296 | 378 | mock_temporary_file.reset_mock(return_value=True) | ||
297 | 379 | mock_sys_exit.reset_mock() | ||
298 | 380 | |||
299 | 381 | mock_temporary_file.return_value.__enter__.side_effect = Exception() | ||
300 | 382 | cli_instance._check_output_folder() | ||
301 | 383 | mock_sys_exit.assert_called_once_with(1) | ||
302 | 384 | |||
303 | 385 | |||
304 | 350 | @pytest.mark.parametrize("audit_type", ["file", "all", None]) | 386 | @pytest.mark.parametrize("audit_type", ["file", "all", None]) |
305 | 351 | def test_main(cli_instance, audit_type, mocker): | 387 | def test_main(cli_instance, audit_type, mocker): |
306 | 352 | """Test main entrypoint of jujulint.""" | 388 | """Test main entrypoint of jujulint.""" |
This merge proposal is being monitored by mergebot. Change the status to Approved to merge.