blob: 8b0968c5765ad942513d9fcf726738b678b23a40 [file] [log] [blame]
Simon Glass2c266d82025-04-29 07:22:13 -06001# SPDX-License-Identifier: GPL-2.0+
2#
3# Copyright 2025 Simon Glass <sjg@chromium.org>
4#
5"""Provides a basic API for the patchwork server
6"""
7
8import asyncio
Simon Glass232eefd2025-04-29 07:22:14 -06009import re
Simon Glass2c266d82025-04-29 07:22:13 -060010
11import aiohttp
12
13# Number of retries
14RETRIES = 3
15
16# Max concurrent request
17MAX_CONCURRENT = 50
18
Simon Glass232eefd2025-04-29 07:22:14 -060019# Patches which are part of a multi-patch series are shown with a prefix like
20# [prefix, version, sequence], for example '[RFC, v2, 3/5]'. All but the last
21# part is optional. This decodes the string into groups. For single patches
22# the [] part is not present:
23# Groups: (ignore, ignore, ignore, prefix, version, sequence, subject)
24RE_PATCH = re.compile(r'(\[(((.*),)?(.*),)?(.*)\]\s)?(.*)$')
25
26# This decodes the sequence string into a patch number and patch count
27RE_SEQ = re.compile(r'(\d+)/(\d+)')
28
29
30class Patch(dict):
31 """Models a patch in patchwork
32
33 This class records information obtained from patchwork
34
35 Some of this information comes from the 'Patch' column:
36
37 [RFC,v2,1/3] dm: Driver and uclass changes for tiny-dm
38
39 This shows the prefix, version, seq, count and subject.
40
41 The other properties come from other columns in the display.
42
43 Properties:
44 pid (str): ID of the patch (typically an integer)
45 seq (int): Sequence number within series (1=first) parsed from sequence
46 string
47 count (int): Number of patches in series, parsed from sequence string
48 raw_subject (str): Entire subject line, e.g.
49 "[1/2,v2] efi_loader: Sort header file ordering"
50 prefix (str): Prefix string or None (e.g. 'RFC')
51 version (str): Version string or None (e.g. 'v2')
52 raw_subject (str): Raw patch subject
53 subject (str): Patch subject with [..] part removed (same as commit
54 subject)
Simon Glassa1046c82025-04-29 07:22:22 -060055 data (dict or None): Patch data:
Simon Glass232eefd2025-04-29 07:22:14 -060056 """
Simon Glassa1046c82025-04-29 07:22:22 -060057 def __init__(self, pid, state=None, data=None, comments=None,
58 series_data=None):
Simon Glass232eefd2025-04-29 07:22:14 -060059 super().__init__()
60 self.id = pid # Use 'id' to match what the Rest API provides
61 self.seq = None
62 self.count = None
63 self.prefix = None
64 self.version = None
65 self.raw_subject = None
66 self.subject = None
Simon Glassa1046c82025-04-29 07:22:22 -060067 self.state = state
68 self.data = data
69 self.comments = comments
70 self.series_data = series_data
71 self.name = None
Simon Glass232eefd2025-04-29 07:22:14 -060072
73 # These make us more like a dictionary
74 def __setattr__(self, name, value):
75 self[name] = value
76
77 def __getattr__(self, name):
78 return self[name]
79
80 def __hash__(self):
81 return hash(frozenset(self.items()))
82
83 def __str__(self):
84 return self.raw_subject
85
86 def parse_subject(self, raw_subject):
87 """Parse the subject of a patch into its component parts
88
89 See RE_PATCH for details. The parsed info is placed into seq, count,
90 prefix, version, subject
91
92 Args:
93 raw_subject (str): Subject string to parse
94
95 Raises:
96 ValueError: the subject cannot be parsed
97 """
98 self.raw_subject = raw_subject.strip()
99 mat = RE_PATCH.search(raw_subject.strip())
100 if not mat:
101 raise ValueError(f"Cannot parse subject '{raw_subject}'")
102 self.prefix, self.version, seq_info, self.subject = mat.groups()[3:]
103 mat_seq = RE_SEQ.match(seq_info) if seq_info else False
104 if mat_seq is None:
105 self.version = seq_info
106 seq_info = None
107 if self.version and not self.version.startswith('v'):
108 self.prefix = self.version
109 self.version = None
110 if seq_info:
111 if mat_seq:
112 self.seq = int(mat_seq.group(1))
113 self.count = int(mat_seq.group(2))
114 else:
115 self.seq = 1
116 self.count = 1
117
118
119class Review:
120 """Represents a single review email collected in Patchwork
121
122 Patches can attract multiple reviews. Each consists of an author/date and
123 a variable number of 'snippets', which are groups of quoted and unquoted
124 text.
125 """
126 def __init__(self, meta, snippets):
127 """Create new Review object
128
129 Args:
130 meta (str): Text containing review author and date
131 snippets (list): List of snippets in th review, each a list of text
132 lines
133 """
134 self.meta = ' : '.join([line for line in meta.splitlines() if line])
135 self.snippets = snippets
136
137
Simon Glass2c266d82025-04-29 07:22:13 -0600138class Patchwork:
139 """Class to handle communication with patchwork
140 """
141 def __init__(self, url, show_progress=True):
142 """Set up a new patchwork handler
143
144 Args:
145 url (str): URL of patchwork server, e.g.
146 'https://patchwork.ozlabs.org'
147 """
148 self.url = url
Simon Glass25b91c12025-04-29 07:22:19 -0600149 self.fake_request = None
Simon Glass2c266d82025-04-29 07:22:13 -0600150 self.proj_id = None
151 self.link_name = None
152 self._show_progress = show_progress
153 self.semaphore = asyncio.Semaphore(MAX_CONCURRENT)
154 self.request_count = 0
155
156 async def _request(self, client, subpath):
157 """Call the patchwork API and return the result as JSON
158
159 Args:
160 client (aiohttp.ClientSession): Session to use
161 subpath (str): URL subpath to use
162
163 Returns:
164 dict: Json result
165
166 Raises:
167 ValueError: the URL could not be read
168 """
169 # print('subpath', subpath)
170 self.request_count += 1
Simon Glass25b91c12025-04-29 07:22:19 -0600171 if self.fake_request:
172 return self.fake_request(subpath)
Simon Glass2c266d82025-04-29 07:22:13 -0600173
174 full_url = f'{self.url}/api/1.2/{subpath}'
175 async with self.semaphore:
176 # print('full_url', full_url)
177 for i in range(RETRIES + 1):
178 try:
179 async with client.get(full_url) as response:
180 if response.status != 200:
181 raise ValueError(
182 f"Could not read URL '{full_url}'")
183 result = await response.json()
184 # print('- done', full_url)
185 return result
186 break
187 except aiohttp.client_exceptions.ServerDisconnectedError:
188 if i == RETRIES:
189 raise
Simon Glass1568b692025-04-29 07:22:15 -0600190
Simon Glass25b91c12025-04-29 07:22:19 -0600191 async def session_request(self, subpath):
192 async with aiohttp.ClientSession() as client:
193 return await self._request(client, subpath)
194
195 def request(self, subpath):
196 return asyncio.run(self.session_request(subpath))
197
198 @staticmethod
199 def for_testing(func):
200 """Get an instance to use for testing
201
202 Args:
203 func (function): Function to call to handle requests. The function
204 is passed a URL and is expected to return a dict with the
205 resulting data
206
207 Returns:
208 Patchwork: testing instance
209 """
210 pwork = Patchwork(None, show_progress=False)
211 pwork.fake_request = func
212 return pwork
213
Simon Glass1568b692025-04-29 07:22:15 -0600214 async def get_series(self, client, link):
215 """Read information about a series
216
217 Args:
218 client (aiohttp.ClientSession): Session to use
219 link (str): Patchwork series ID
220
221 Returns: dict containing patchwork's series information
222 id (int): series ID unique across patchwork instance, e.g. 3
223 url (str): Full URL, e.g.
224 'https://patchwork.ozlabs.org/api/1.2/series/3/'
225 web_url (str): Full URL, e.g.
226 'https://patchwork.ozlabs.org/project/uboot/list/?series=3
227 project (dict): project information (id, url, name, link_name,
228 list_id, list_email, etc.
229 name (str): Series name, e.g. '[U-Boot] moveconfig: fix error'
230 date (str): Date, e.g. '2017-08-27T08:00:51'
231 submitter (dict): id, url, name, email, e.g.:
232 "id": 6125,
233 "url": "https://patchwork.ozlabs.org/api/1.2/people/6125/",
234 "name": "Chris Packham",
235 "email": "judge.packham@gmail.com"
236 version (int): Version number
237 total (int): Total number of patches based on subject
238 received_total (int): Total patches received by patchwork
239 received_all (bool): True if all patches were received
240 mbox (str): URL of mailbox, e.g.
241 'https://patchwork.ozlabs.org/series/3/mbox/'
242 cover_letter (dict) or None, e.g.:
243 "id": 806215,
244 "url": "https://patchwork.ozlabs.org/api/1.2/covers/806215/",
245 "web_url": "https://patchwork.ozlabs.org/project/uboot/cover/
246 20170827094411.8583-1-judge.packham@gmail.com/",
247 "msgid": "<20170827094411.8583-1-judge.packham@gmail.com>",
248 "list_archive_url": null,
249 "date": "2017-08-27T09:44:07",
250 "name": "[U-Boot,v2,0/4] usb: net: Migrate USB Ethernet",
251 "mbox": "https://patchwork.ozlabs.org/project/uboot/cover/
252 20170827094411.8583-1-judge.packham@gmail.com/mbox/"
253 patches (list of dict), each e.g.:
254 "id": 806202,
255 "url": "https://patchwork.ozlabs.org/api/1.2/patches/806202/",
256 "web_url": "https://patchwork.ozlabs.org/project/uboot/patch/
257 20170827080051.816-1-judge.packham@gmail.com/",
258 "msgid": "<20170827080051.816-1-judge.packham@gmail.com>",
259 "list_archive_url": null,
260 "date": "2017-08-27T08:00:51",
261 "name": "[U-Boot] moveconfig: fix error message do_autoconf()",
262 "mbox": "https://patchwork.ozlabs.org/project/uboot/patch/
263 20170827080051.816-1-judge.packham@gmail.com/mbox/"
264 """
265 return await self._request(client, f'series/{link}/')
266
267 async def get_patch(self, client, patch_id):
268 """Read information about a patch
269
270 Args:
271 client (aiohttp.ClientSession): Session to use
272 patch_id (str): Patchwork patch ID
273
274 Returns: dict containing patchwork's patch information
275 "id": 185,
276 "url": "https://patchwork.ozlabs.org/api/1.2/patches/185/",
277 "web_url": "https://patchwork.ozlabs.org/project/cbe-oss-dev/patch/
278 200809050416.27831.adetsch@br.ibm.com/",
279 project (dict): project information (id, url, name, link_name,
280 list_id, list_email, etc.
281 "msgid": "<200809050416.27831.adetsch@br.ibm.com>",
282 "list_archive_url": null,
283 "date": "2008-09-05T07:16:27",
284 "name": "powerpc/spufs: Fix possible scheduling of a context",
285 "commit_ref": "b2e601d14deb2083e2a537b47869ab3895d23a28",
286 "pull_url": null,
287 "state": "accepted",
288 "archived": false,
289 "hash": "bc1c0b80d7cff66c0d1e5f3f8f4d10eb36176f0d",
290 "submitter": {
291 "id": 93,
292 "url": "https://patchwork.ozlabs.org/api/1.2/people/93/",
293 "name": "Andre Detsch",
294 "email": "adetsch@br.ibm.com"
295 },
296 "delegate": {
297 "id": 1,
298 "url": "https://patchwork.ozlabs.org/api/1.2/users/1/",
299 "username": "jk",
300 "first_name": "Jeremy",
301 "last_name": "Kerr",
302 "email": "jk@ozlabs.org"
303 },
304 "mbox": "https://patchwork.ozlabs.org/project/cbe-oss-dev/patch/
305 200809050416.27831.adetsch@br.ibm.com/mbox/",
306 "series": [],
307 "comments": "https://patchwork.ozlabs.org/api/patches/185/
308 comments/",
309 "check": "pending",
310 "checks": "https://patchwork.ozlabs.org/api/patches/185/checks/",
311 "tags": {},
312 "related": [],
313 "headers": {...}
314 "content": "We currently have a race when scheduling a context
315 after we have found a runnable context in spusched_tick, the
316 context may have been scheduled by spu_activate().
317
318 This may result in a panic if we try to unschedule a context
319 been freed in the meantime.
320
321 This change exits spu_schedule() if the context has already
322 scheduled, so we don't end up scheduling it twice.
323
324 Signed-off-by: Andre Detsch <adetsch@br.ibm.com>",
325 "diff": '''Index: spufs/arch/powerpc/platforms/cell/spufs/sched.c
326 =======================================================
327 --- spufs.orig/arch/powerpc/platforms/cell/spufs/sched.c
328 +++ spufs/arch/powerpc/platforms/cell/spufs/sched.c
329 @@ -727,7 +727,8 @@ static void spu_schedule(struct spu *spu
330 \t/* not a candidate for interruptible because it's called
331 \t from the scheduler thread or from spu_deactivate */
332 \tmutex_lock(&ctx->state_mutex);
333 -\t__spu_schedule(spu, ctx);
334 +\tif (ctx->state == SPU_STATE_SAVED)
335 +\t\t__spu_schedule(spu, ctx);
336 \tspu_release(ctx);
337 }
338 '''
339 "prefixes": ["3/3", ...]
340 """
341 return await self._request(client, f'patches/{patch_id}/')
342
343 async def _get_patch_comments(self, client, patch_id):
344 """Read comments about a patch
345
346 Args:
347 client (aiohttp.ClientSession): Session to use
348 patch_id (str): Patchwork patch ID
349
350 Returns: list of dict: list of comments:
351 id (int): series ID unique across patchwork instance, e.g. 3331924
352 web_url (str): Full URL, e.g.
353 'https://patchwork.ozlabs.org/comment/3331924/'
354 msgid (str): Message ID, e.g.
355 '<d2526c98-8198-4b8b-ab10-20bda0151da1@gmx.de>'
356 list_archive_url: (unknown?)
357 date (str): Date, e.g. '2024-06-20T13:38:03'
358 subject (str): email subject, e.g. 'Re: [PATCH 3/5] buildman:
359 Support building within a Python venv'
360 date (str): Date, e.g. '2017-08-27T08:00:51'
361 submitter (dict): id, url, name, email, e.g.:
362 "id": 61270,
363 "url": "https://patchwork.ozlabs.org/api/people/61270/",
364 "name": "Heinrich Schuchardt",
365 "email": "xypron.glpk@gmx.de"
366 content (str): Content of email, e.g. 'On 20.06.24 15:19,
367 Simon Glass wrote:
368 >...'
369 headers: dict: email headers, see get_cover() for an example
370 """
371 return await self._request(client, f'patches/{patch_id}/comments/')
372
373 async def get_patch_comments(self, patch_id):
374 async with aiohttp.ClientSession() as client:
375 return await self._get_patch_comments(client, patch_id)
376
377 async def _get_patch_status(self, client, patch_id):
378 """Get the patch status
379
380 Args:
381 client (aiohttp.ClientSession): Session to use
382 patch_id (int): Patch ID to look up in patchwork
383
384 Return:
385 PATCH: Patch information
386
387 Requests:
388 1 for patch, 1 for patch comments
389 """
390 data = await self.get_patch(client, patch_id)
391 state = data['state']
392 comment_data = await self._get_patch_comments(client, patch_id)
393
394 return Patch(patch_id, state, data, comment_data)
395
396 async def series_get_state(self, client, link, read_comments):
397 """Sync the series information against patchwork, to find patch status
398
399 Args:
400 client (aiohttp.ClientSession): Session to use
401 link (str): Patchwork series ID
402 read_comments (bool): True to read the comments on the patches
403
404 Return: tuple:
405 list of Patch objects
406 """
407 data = await self.get_series(client, link)
408 patch_list = list(data['patches'])
409
410 count = len(patch_list)
411 patches = []
412 if read_comments:
413 # Returns a list of Patch objects
414 tasks = [self._get_patch_status(client, patch_list[i]['id'])
415 for i in range(count)]
416
417 patch_status = await asyncio.gather(*tasks)
418 for patch_data, status in zip(patch_list, patch_status):
419 status.series_data = patch_data
420 patches.append(status)
421 else:
422 for i in range(count):
423 info = patch_list[i]
424 pat = Patch(info['id'], series_data=info)
425 pat.raw_subject = info['name']
426 patches.append(pat)
427 if self._show_progress:
428 terminal.print_clear()
429
430 return patches