From dcef301d796d3d7d7b66812fb337843a275ae6df Mon Sep 17 00:00:00 2001 From: Matthieu Huin Date: Thu, 10 Sep 2020 16:05:41 +0200 Subject: [PATCH] Add builds subcommand Allow a user to search builds according to filtering criteria. Change-Id: Ibde3730d00f623df7ff6716a034af88de89f4835 --- doc/source/commands.rst | 9 ++ .../builds_subcommand-faf77b30d8e13ccb.yaml | 5 + tests/unit/test_cmd.py | 29 +++++ zuulclient/api/__init__.py | 21 ++++ zuulclient/cmd/__init__.py | 109 ++++++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 releasenotes/notes/builds_subcommand-faf77b30d8e13ccb.yaml diff --git a/doc/source/commands.rst b/doc/source/commands.rst index d880b8f..fa1604f 100644 --- a/doc/source/commands.rst +++ b/doc/source/commands.rst @@ -55,6 +55,15 @@ Example:: zuul-client autohold-list --tenant openstack +Builds +^^^^^^ +.. program-output:: zuul-client builds --help + +Examples:: + + zuul-client --use-conf sfio builds --tenant mytenant --result NODE_FAILURE + zuul-client --use-conf opendev builds --tenant zuul --project zuul/zuul-client --limit 10 + Dequeue ^^^^^^^ diff --git a/releasenotes/notes/builds_subcommand-faf77b30d8e13ccb.yaml b/releasenotes/notes/builds_subcommand-faf77b30d8e13ccb.yaml new file mode 100644 index 0000000..3c5b052 --- /dev/null +++ b/releasenotes/notes/builds_subcommand-faf77b30d8e13ccb.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Add the **builds** subcommand to zuul-client, allowing users to search through + builds using filters. diff --git a/tests/unit/test_cmd.py b/tests/unit/test_cmd.py index 6f9a213..92fc54b 100644 --- a/tests/unit/test_cmd.py +++ b/tests/unit/test_cmd.py @@ -468,3 +468,32 @@ class TestCmd(BaseTestCase): self.assertEqual(secret, f.read()) os.unlink(infile.name) os.unlink(outfile.name) + + def test_builds(self): + """Test builds subcommand""" + ZC = ZuulClient() + with self.assertRaisesRegex(Exception, + '--voting and --non-voting are ' + 'mutually exclusive'): + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', + 'builds', '--tenant', 'tenant1', '--voting', '--non-voting']) + with patch('requests.Session') as mock_sesh: + session = mock_sesh.return_value + session.post = MagicMock( + return_value=FakeRequestResponse(200, {})) + exit_code = ZC._main( + ['--zuul-url', 'https://fake.zuul', 'builds', + '--pipeline', 'gate', + '--tenant', 'tenant1', + '--change', '1234', '--job', 'job1', '--held']) + session.get.assert_called_with( + 'https://fake.zuul/api/tenant/tenant1/builds', + params={'pipeline': 'gate', + 'change': '1234', + 'job_name': 'job1', + 'held': True, + 'skip': 0, + 'limit': 50} + ) + self.assertEqual(0, exit_code) diff --git a/zuulclient/api/__init__.py b/zuulclient/api/__init__.py index 9125d55..3c5b910 100644 --- a/zuulclient/api/__init__.py +++ b/zuulclient/api/__init__.py @@ -226,3 +226,24 @@ class ZuulRESTClient(object): req = self.session.get(url) self._check_request_status(req) return req.text + + def builds(self, tenant, **kwargs): + # check kwargs + allowed_args = {'project', 'pipeline', 'change', 'branch', 'patchset', + 'ref', 'newrev', 'uuid', 'job_name', 'voting', + 'node_name', 'result', 'final', 'held', + 'limit', 'skip'} + if not set(kwargs.keys()).issubset(allowed_args): + raise Exception( + 'Allowed arguments are %s' % ', '.join(allowed_args)) + params = kwargs + if 'limit' not in params: + params['limit'] = 50 + if 'skip' not in params: + params['skip'] = 0 + url = urllib.parse.urljoin( + self.base_url, + 'tenant/%s/builds' % tenant) + req = self.session.get(url, params=kwargs) + self._check_request_status(req) + return req.json() diff --git a/zuulclient/cmd/__init__.py b/zuulclient/cmd/__init__.py index 6a689ff..c30b211 100644 --- a/zuulclient/cmd/__init__.py +++ b/zuulclient/cmd/__init__.py @@ -87,6 +87,8 @@ class ZuulClient(): self.add_dequeue_subparser(subparsers) self.add_promote_subparser(subparsers) self.add_encrypt_subparser(subparsers) + self.add_builds_list_subparser(subparsers) + return subparsers def parseArguments(self, args=None): @@ -530,6 +532,113 @@ class ZuulClient(): os.unlink(pubkey_file.name) return return_code + def add_builds_list_subparser(self, subparsers): + cmd_builds = subparsers.add_parser( + 'builds', help='List builds matching search criteria') + cmd_builds.add_argument( + '--tenant', help='tenant name', required=True) + cmd_builds.add_argument( + '--project', help='project name') + cmd_builds.add_argument( + '--pipeline', help='pipeline name') + cmd_builds.add_argument( + '--change', help='change reference') + cmd_builds.add_argument( + '--branch', help='branch name') + cmd_builds.add_argument( + '--patchset', help='patchset number') + cmd_builds.add_argument( + '--ref', help='ref name') + cmd_builds.add_argument( + '--newrev', help='the applied revision') + cmd_builds.add_argument( + '--job', help='job name') + cmd_builds.add_argument( + '--voting', help='show voting builds only', + action='store_true', default=False) + cmd_builds.add_argument( + '--non-voting', help='show non-voting builds only', + action='store_true', default=False) + cmd_builds.add_argument( + '--node', help='node name') + cmd_builds.add_argument( + '--result', help='build result') + cmd_builds.add_argument( + '--final', help='show final builds only', + action='store_true', default=False) + cmd_builds.add_argument( + '--held', help='show held builds only', + action='store_true', default=False) + cmd_builds.add_argument( + '--limit', help='maximum amount of results to return', + default=50, type=int) + cmd_builds.add_argument( + '--skip', help='how many results to skip', + default=0, type=int) + cmd_builds.set_defaults(func=self.builds) + + def builds(self): + if self.args.voting and self.args.non_voting: + raise Exception('--voting and --non-voting are mutually exclusive') + self.log.info('Showing the last {} matches.'.format(self.args.limit)) + filters = {'limit': self.args.limit, + 'skip': self.args.skip} + if self.args.project: + filters['project'] = self.args.project + if self.args.pipeline: + filters['pipeline'] = self.args.pipeline + if self.args.change: + filters['change'] = self.args.change + if self.args.branch: + filters['branch'] = self.args.branch + if self.args.patchset: + filters['patchset'] = self.args.patchset + if self.args.ref: + filters['ref'] = self.args.ref + if self.args.newrev: + filters['newrev'] = self.args.newrev + if self.args.job: + filters['job_name'] = self.args.job + if self.args.voting: + filters['voting'] = True + if self.args.non_voting: + filters['voting'] = False + if self.args.node: + filters['node'] = self.args.node + if self.args.result: + filters['result'] = self.args.result + if self.args.final: + filters['final'] = True + if self.args.held: + filters['held'] = True + client = self.get_client() + builds = client.builds(tenant=self.args.tenant, **filters) + table = prettytable.PrettyTable( + field_names=[ + 'ID', 'Job', 'Project', 'Branch', 'Pipeline', 'Change or Ref', + 'Duration (s)', 'Start time', 'Result', 'Event ID' + ] + ) + for build in builds: + if build['change'] and build['patchset']: + change = str(build['change']) + ',' + str(build['patchset']) + else: + change = build['ref'] + table.add_row([ + build.get('uuid') or 'N/A', + build['job_name'], + build['project'], + build['branch'], + build['pipeline'], + change, + build['duration'], + build['start_time'], + build['result'], + build.get('event_id') or 'N/A' + ]) + print(table) + return True + def main(): ZuulClient().main()