diff --git a/pwclient/api.py b/pwclient/api.py index b736a41..e747d9b 100644 --- a/pwclient/api.py +++ b/pwclient/api.py @@ -135,6 +135,12 @@ def check_create( ): pass + # events + + @abc.abstractmethod + def event_list(self, project=None, category=None, since=None): + pass + class XMLRPC(API): def __init__( @@ -428,6 +434,11 @@ def check_create( except xmlrpclib.Fault as f: raise exceptions.APIError(f'Error creating check: {f.faultString}') + def event_list(self, project=None, category=None, since=None): + raise NotImplementedError( + 'Events are not supported by the XML-RPC API' + ) + class REST(API): def __init__( @@ -975,3 +986,38 @@ def check_create( 'description': description, }, ) + + @staticmethod + def _event_to_dict(obj): + """Serialize an event response.""" + result = { + 'id': obj['id'], + 'category': obj['category'], + 'date': obj['date'], + } + + series = obj.get('payload', {}).get('series', {}) + if series: + result['series_id'] = series.get('id', '') + result['series_url'] = series.get('url', '') + result['series_name'] = series.get('name', '') + result['series_mbox'] = series.get('mbox', '') + + return result + + def event_list(self, project=None, category=None, since=None): + filters = {} + + if project: + filters['project'] = project + + if category: + filters['category'] = category + + if since: + filters['since'] = since + + return ( + self._event_to_dict(event) + for event in self._list('events', params=filters) + ) diff --git a/pwclient/events.py b/pwclient/events.py new file mode 100644 index 0000000..9acb8b0 --- /dev/null +++ b/pwclient/events.py @@ -0,0 +1,33 @@ +# Patchwork command line client +# Copyright (C) 2026 Andrea Cervesato +# +# SPDX-License-Identifier: GPL-2.0-or-later + +import re + + +def action_list(api, project=None, category=None, since=None, format_str=None): + events = api.event_list(project=project, category=category, since=since) + + if format_str: + format_field_re = re.compile('%{([a-z0-9_]+)}') + + def event_field(matchobj): + fieldname = matchobj.group(1) + return str(event[fieldname]) + + for event in events: + print(format_field_re.sub(event_field, format_str)) + else: + print("%-10s %-24s %-24s %s" % ("ID", "Category", "Date", "Series")) + print("%-10s %-24s %-24s %s" % ("--", "--------", "----", "------")) + for event in events: + print( + "%-10d %-24s %-24s %s" + % ( + event['id'], + event['category'], + event['date'], + event.get('series_name', ''), + ) + ) diff --git a/pwclient/parser.py b/pwclient/parser.py index be6dd72..3980400 100644 --- a/pwclient/parser.py +++ b/pwclient/parser.py @@ -279,6 +279,38 @@ def get_parser(): ) check_create_parser.set_defaults(subcmd='check_create') + event_list_parser = subparsers.add_parser( + 'event-list', + help="list events", + ) + event_list_parser.add_argument( + '-p', + '--project', + metavar='PROJECT', + help="filter by project name (see 'projects' for list)", + ) + event_list_parser.add_argument( + '-c', + '--category', + metavar='CATEGORY', + help="filter by event category (e.g. 'series-completed')", + ) + event_list_parser.add_argument( + '--since', + metavar='SINCE', + help="show only events since a given date in ISO 8601 format", + ) + event_list_parser.add_argument( + '-f', + '--format', + metavar='FORMAT', + help=( + "print output in the given format. You can use tags matching " + "fields, e.g. %%{id}, %%{category}, or %%{series_id}." + ), + ) + event_list_parser.set_defaults(subcmd='event_list') + states_parser = subparsers.add_parser( 'states', help="show list of potential patch states" ) diff --git a/pwclient/shell.py b/pwclient/shell.py index a3a9f27..1941f00 100644 --- a/pwclient/shell.py +++ b/pwclient/shell.py @@ -11,6 +11,7 @@ from . import api as pw_api from . import checks +from . import events from . import exceptions from . import parser from . import patches @@ -174,6 +175,15 @@ def main(argv=sys.argv[1:]): format_str=args.format, ) + elif action == 'event_list': + events.action_list( + api, + project=args.project, + category=args.category, + since=args.since, + format_str=args.format, + ) + elif action.startswith('project'): projects.action_list(api) diff --git a/tests/fakes.py b/tests/fakes.py index 2d8f9d1..cad21ee 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -114,3 +114,17 @@ def fake_states(): 'name': 'New', } ] + + +def fake_events(): + return [ + { + 'id': 1, + 'category': 'series-completed', + 'date': '2026-04-10T06:00:02', + 'series_id': 499401, + 'series_name': '[v3] growfiles: fix test failure', + 'series_url': 'http://patchwork.ozlabs.org/api/series/499401/', + 'series_mbox': 'http://patchwork.ozlabs.org/series/499401/mbox/', + }, + ] diff --git a/tests/test_shell.py b/tests/test_shell.py index 63f7dbe..5497bcb 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -4,6 +4,7 @@ from pwclient import api from pwclient import checks +from pwclient import events from pwclient import exceptions from pwclient import patches from pwclient import projects @@ -339,6 +340,34 @@ def test_check_list(mock_action, mock_api, mock_config): mock_action.assert_called_once_with(mock_api.return_value, None, None) +@mock.patch.object(utils.configparser, 'ConfigParser') +@mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) +@mock.patch.object(api, 'XMLRPC') +@mock.patch.object(events, 'action_list') +def test_event_list(mock_action, mock_api, mock_config): + mock_config.return_value = FakeConfig() + + shell.main( + [ + 'event-list', + '-p', + DEFAULT_PROJECT, + '-c', + 'series-completed', + '--since', + '2026-04-09T00:00:00Z', + ] + ) + + mock_action.assert_called_once_with( + mock_api.return_value, + project=DEFAULT_PROJECT, + category='series-completed', + since='2026-04-09T00:00:00Z', + format_str=None, + ) + + @mock.patch.object(utils.configparser, 'ConfigParser') @mock.patch.object(shell.os.path, 'exists', new=mock.Mock(return_value=True)) @mock.patch.object(api, 'XMLRPC')