1# 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
9import re
10
11import aiohttp
12from collections import namedtuple
13
14from u_boot_pylib import terminal
15
16# Information passed to series_get_states()
17# link (str): Patchwork link for series
18# series_id (int): Series ID in database
19# series_name (str): Series name
20# version (int): Version number of series
21# show_comments (bool): True to show comments
22# show_cover_comments (bool): True to show cover-letter comments
23STATE_REQ = namedtuple(
24    'state_req',
25    'link,series_id,series_name,version,show_comments,show_cover_comments')
26
27# Responses from series_get_states()
28# int: ser_ver ID number
29# COVER: Cover-letter info
30# list of Patch: Information on each patch in the series
31# list of dict: patches, see get_series()['patches']
32STATE_RESP = namedtuple('state_resp', 'svid,cover,patches,patch_list')
33
34# Information about a cover-letter on patchwork
35# id (int): Patchwork ID of cover letter
36# state (str): Current state, e.g. 'accepted'
37# num_comments (int): Number of comments
38# name (str): Series name
39# comments (list of dict): Comments
40COVER = namedtuple('cover', 'id,num_comments,name,comments')
41
42# Number of retries
43RETRIES = 3
44
45# Max concurrent request
46MAX_CONCURRENT = 50
47
48# Patches which are part of a multi-patch series are shown with a prefix like
49# [prefix, version, sequence], for example '[RFC, v2, 3/5]'. All but the last
50# part is optional. This decodes the string into groups. For single patches
51# the [] part is not present:
52# Groups: (ignore, ignore, ignore, prefix, version, sequence, subject)
53RE_PATCH = re.compile(r'(\[(((.*),)?(.*),)?(.*)\]\s)?(.*)$')
54
55# This decodes the sequence string into a patch number and patch count
56RE_SEQ = re.compile(r'(\d+)/(\d+)')
57
58
59class Patch(dict):
60    """Models a patch in patchwork
61
62    This class records information obtained from patchwork
63
64    Some of this information comes from the 'Patch' column:
65
66        [RFC,v2,1/3] dm: Driver and uclass changes for tiny-dm
67
68    This shows the prefix, version, seq, count and subject.
69
70    The other properties come from other columns in the display.
71
72    Properties:
73        pid (str): ID of the patch (typically an integer)
74        seq (int): Sequence number within series (1=first) parsed from sequence
75            string
76        count (int): Number of patches in series, parsed from sequence string
77        raw_subject (str): Entire subject line, e.g.
78            "[1/2,v2] efi_loader: Sort header file ordering"
79        prefix (str): Prefix string or None (e.g. 'RFC')
80        version (str): Version string or None (e.g. 'v2')
81        raw_subject (str): Raw patch subject
82        subject (str): Patch subject with [..] part removed (same as commit
83            subject)
84        data (dict or None): Patch data:
85    """
86    def __init__(self, pid, state=None, data=None, comments=None,
87                 series_data=None):
88        super().__init__()
89        self.id = pid  # Use 'id' to match what the Rest API provides
90        self.seq = None
91        self.count = None
92        self.prefix = None
93        self.version = None
94        self.raw_subject = None
95        self.subject = None
96        self.state = state
97        self.data = data
98        self.comments = comments
99        self.series_data = series_data
100        self.name = None
101
102    # These make us more like a dictionary
103    def __setattr__(self, name, value):
104        self[name] = value
105
106    def __getattr__(self, name):
107        return self[name]
108
109    def __hash__(self):
110        return hash(frozenset(self.items()))
111
112    def __str__(self):
113        return self.raw_subject
114
115    def parse_subject(self, raw_subject):
116        """Parse the subject of a patch into its component parts
117
118        See RE_PATCH for details. The parsed info is placed into seq, count,
119        prefix, version, subject
120
121        Args:
122            raw_subject (str): Subject string to parse
123
124        Raises:
125            ValueError: the subject cannot be parsed
126        """
127        self.raw_subject = raw_subject.strip()
128        mat = RE_PATCH.search(raw_subject.strip())
129        if not mat:
130            raise ValueError(f"Cannot parse subject '{raw_subject}'")
131        self.prefix, self.version, seq_info, self.subject = mat.groups()[3:]
132        mat_seq = RE_SEQ.match(seq_info) if seq_info else False
133        if mat_seq is None:
134            self.version = seq_info
135            seq_info = None
136        if self.version and not self.version.startswith('v'):
137            self.prefix = self.version
138            self.version = None
139        if seq_info:
140            if mat_seq:
141                self.seq = int(mat_seq.group(1))
142                self.count = int(mat_seq.group(2))
143        else:
144            self.seq = 1
145            self.count = 1
146
147
148class Review:
149    """Represents a single review email collected in Patchwork
150
151    Patches can attract multiple reviews. Each consists of an author/date and
152    a variable number of 'snippets', which are groups of quoted and unquoted
153    text.
154    """
155    def __init__(self, meta, snippets):
156        """Create new Review object
157
158        Args:
159            meta (str): Text containing review author and date
160            snippets (list): List of snippets in th review, each a list of text
161                lines
162        """
163        self.meta = ' : '.join([line for line in meta.splitlines() if line])
164        self.snippets = snippets
165
166
167class Patchwork:
168    """Class to handle communication with patchwork
169    """
170    def __init__(self, url, show_progress=True, single_thread=False):
171        """Set up a new patchwork handler
172
173        Args:
174            url (str): URL of patchwork server, e.g.
175               'https://patchwork.ozlabs.org'
176        """
177        self.url = url
178        self.fake_request = None
179        self.proj_id = None
180        self.link_name = None
181        self._show_progress = show_progress
182        self.semaphore = asyncio.Semaphore(
183            1 if single_thread else MAX_CONCURRENT)
184        self.request_count = 0
185
186    async def _request(self, client, subpath):
187        """Call the patchwork API and return the result as JSON
188
189        Args:
190            client (aiohttp.ClientSession): Session to use
191            subpath (str): URL subpath to use
192
193        Returns:
194            dict: Json result
195
196        Raises:
197            ValueError: the URL could not be read
198        """
199        # print('subpath', subpath)
200        self.request_count += 1
201        if self.fake_request:
202            return self.fake_request(subpath)
203
204        full_url = f'{self.url}/api/1.2/{subpath}'
205        async with self.semaphore:
206            # print('full_url', full_url)
207            for i in range(RETRIES + 1):
208                try:
209                    async with client.get(full_url) as response:
210                        if response.status != 200:
211                            raise ValueError(
212                                f"Could not read URL '{full_url}'")
213                        result = await response.json()
214                        # print('- done', full_url)
215                        return result
216                    break
217                except aiohttp.client_exceptions.ServerDisconnectedError:
218                    if i == RETRIES:
219                        raise
220
221    @staticmethod
222    def for_testing(func):
223        """Get an instance to use for testing
224
225        Args:
226            func (function): Function to call to handle requests. The function
227                is passed a URL and is expected to return a dict with the
228                resulting data
229
230        Returns:
231            Patchwork: testing instance
232        """
233        pwork = Patchwork(None, show_progress=False)
234        pwork.fake_request = func
235        return pwork
236
237    class _Stats:
238        def __init__(self, parent):
239            self.parent = parent
240            self.request_count = 0
241
242        def __enter__(self):
243            return self
244
245        def __exit__(self, exc_type, exc_val, exc_tb):
246            self.request_count = self.parent.request_count
247
248    def collect_stats(self):
249        """Context manager to count requests across a range of patchwork calls
250
251        Usage:
252            pwork = Patchwork(...)
253            with pwork.count_requests() as counter:
254                pwork.something()
255            print(f'{counter.count} requests')
256        """
257        self.request_count = 0
258        return self._Stats(self)
259
260    async def get_projects(self):
261        """Get a list of projects on the server
262
263        Returns:
264            list of dict, one for each project
265                'name' (str): Project name, e.g. 'U-Boot'
266                'id' (int): Project ID, e.g. 9
267                'link_name' (str): Project's link-name, e.g. 'uboot'
268        """
269        async with aiohttp.ClientSession() as client:
270            return await self._request(client, 'projects/')
271
272    async def _query_series(self, client, desc):
273        """Query series by name
274
275        Args:
276            client (aiohttp.ClientSession): Session to use
277            desc: String to search for
278
279        Return:
280            list of series matches, each a dict, see get_series()
281        """
282        query = desc.replace(' ', '+')
283        return await self._request(
284            client, f'series/?project={self.proj_id}&q={query}')
285
286    async def _find_series(self, client, svid, ser_id, version, ser):
287        """Find a series on the server
288
289        Args:
290            client (aiohttp.ClientSession): Session to use
291            svid (int): ser_ver ID
292            ser_id (int): series ID
293            version (int): Version number to search for
294            ser (Series): Contains description (cover-letter title)
295
296        Returns:
297            tuple:
298                int: ser_ver ID (as passed in)
299                int: series ID (as passed in)
300                str: Series link, or None if not found
301                list of dict, or None if found
302                    each dict is the server result from a possible series
303        """
304        desc = ser.desc
305        name_found = []
306
307        # Do a series query on the description
308        res = await self._query_series(client, desc)
309        for pws in res:
310            if pws['name'] == desc:
311                if int(pws['version']) == version:
312                    return svid, ser_id, pws['id'], None
313                name_found.append(pws)
314
315        # When there is no cover letter, patchwork uses the first patch as the
316        # series name
317        cmt = ser.commits[0]
318
319        res = await self._query_series(client, cmt.subject)
320        for pws in res:
321            patch = Patch(0)
322            patch.parse_subject(pws['name'])
323            if patch.subject == cmt.subject:
324                if int(pws['version']) == version:
325                    return svid, ser_id, pws['id'], None
326                name_found.append(pws)
327
328        return svid, ser_id, None, name_found or res
329
330    async def find_series(self, ser, version):
331        """Find a series based on its description and version
332
333        Args:
334            ser (Series): Contains description (cover-letter title)
335            version (int): Version number
336
337        Return: tuple:
338            tuple:
339                str: Series ID, or None if not found
340                list of dict, or None if found
341                    each dict is the server result from a possible series
342            int: number of server requests done
343        """
344        async with aiohttp.ClientSession() as client:
345            # We don't know the svid and it isn't needed, so use -1
346            _, _, link, options = await self._find_series(client, -1, -1,
347                                                          version, ser)
348        return link, options
349
350    async def find_series_list(self, to_find):
351        """Find the link for each series in a list
352
353        Args:
354            to_find (dict of svids to sync):
355                key (int): ser_ver ID
356                value (tuple):
357                    int: Series ID
358                    int: Series version
359                    str: Series link
360                    str: Series description
361
362        Return: tuple:
363            list of tuple, one for each item in to_find:
364                int: ser_ver_ID
365                int: series ID
366                int: Series version
367                str: Series link, or None if not found
368                list of dict, or None if found
369                    each dict is the server result from a possible series
370            int: number of server requests done
371        """
372        self.request_count = 0
373        async with aiohttp.ClientSession() as client:
374            tasks = [asyncio.create_task(
375                self._find_series(client, svid, ser_id, version, desc))
376                for svid, (ser_id, version, link, desc) in to_find.items()]
377            results = await asyncio.gather(*tasks)
378
379        return results, self.request_count
380
381    def project_set(self, project_id, link_name):
382        """Set the project ID
383
384        The patchwork server has multiple projects. This allows the ID and
385        link_name of the relevant project to be selected
386
387        This function is used for testing
388
389        Args:
390            project_id (int): Project ID to use, e.g. 6
391            link_name (str): Name to use for project URL links, e.g. 'uboot'
392        """
393        self.proj_id = project_id
394        self.link_name = link_name
395
396    async def get_series(self, client, link):
397        """Read information about a series
398
399        Args:
400            client (aiohttp.ClientSession): Session to use
401            link (str): Patchwork series ID
402
403        Returns: dict containing patchwork's series information
404            id (int): series ID unique across patchwork instance, e.g. 3
405            url (str): Full URL, e.g.
406                'https://patchwork.ozlabs.org/api/1.2/series/3/'
407            web_url (str): Full URL, e.g.
408                'https://patchwork.ozlabs.org/project/uboot/list/?series=3
409            project (dict): project information (id, url, name, link_name,
410                list_id, list_email, etc.
411            name (str): Series name, e.g. '[U-Boot] moveconfig: fix error'
412            date (str): Date, e.g. '2017-08-27T08:00:51'
413            submitter (dict): id, url, name, email, e.g.:
414                "id": 6125,
415                "url": "https://patchwork.ozlabs.org/api/1.2/people/6125/",
416                "name": "Chris Packham",
417                "email": "judge.packham@gmail.com"
418            version (int): Version number
419            total (int): Total number of patches based on subject
420            received_total (int): Total patches received by patchwork
421            received_all (bool): True if all patches were received
422            mbox (str): URL of mailbox, e.g.
423                'https://patchwork.ozlabs.org/series/3/mbox/'
424            cover_letter (dict) or None, e.g.:
425                "id": 806215,
426                "url": "https://patchwork.ozlabs.org/api/1.2/covers/806215/",
427                "web_url": "https://patchwork.ozlabs.org/project/uboot/cover/
428                    20170827094411.8583-1-judge.packham@gmail.com/",
429                "msgid": "<20170827094411.8583-1-judge.packham@gmail.com>",
430                "list_archive_url": null,
431                "date": "2017-08-27T09:44:07",
432                "name": "[U-Boot,v2,0/4] usb: net: Migrate USB Ethernet",
433                "mbox": "https://patchwork.ozlabs.org/project/uboot/cover/
434                    20170827094411.8583-1-judge.packham@gmail.com/mbox/"
435            patches (list of dict), each e.g.:
436                "id": 806202,
437                "url": "https://patchwork.ozlabs.org/api/1.2/patches/806202/",
438                "web_url": "https://patchwork.ozlabs.org/project/uboot/patch/
439                    20170827080051.816-1-judge.packham@gmail.com/",
440                "msgid": "<20170827080051.816-1-judge.packham@gmail.com>",
441                "list_archive_url": null,
442                "date": "2017-08-27T08:00:51",
443                "name": "[U-Boot] moveconfig: fix error message do_autoconf()",
444                "mbox": "https://patchwork.ozlabs.org/project/uboot/patch/
445                    20170827080051.816-1-judge.packham@gmail.com/mbox/"
446        """
447        return await self._request(client, f'series/{link}/')
448
449    async def get_patch(self, client, patch_id):
450        """Read information about a patch
451
452        Args:
453            client (aiohttp.ClientSession): Session to use
454            patch_id (str): Patchwork patch ID
455
456        Returns: dict containing patchwork's patch information
457            "id": 185,
458            "url": "https://patchwork.ozlabs.org/api/1.2/patches/185/",
459            "web_url": "https://patchwork.ozlabs.org/project/cbe-oss-dev/patch/
460                200809050416.27831.adetsch@br.ibm.com/",
461            project (dict): project information (id, url, name, link_name,
462                    list_id, list_email, etc.
463            "msgid": "<200809050416.27831.adetsch@br.ibm.com>",
464            "list_archive_url": null,
465            "date": "2008-09-05T07:16:27",
466            "name": "powerpc/spufs: Fix possible scheduling of a context",
467            "commit_ref": "b2e601d14deb2083e2a537b47869ab3895d23a28",
468            "pull_url": null,
469            "state": "accepted",
470            "archived": false,
471            "hash": "bc1c0b80d7cff66c0d1e5f3f8f4d10eb36176f0d",
472            "submitter": {
473                "id": 93,
474                "url": "https://patchwork.ozlabs.org/api/1.2/people/93/",
475                "name": "Andre Detsch",
476                "email": "adetsch@br.ibm.com"
477            },
478            "delegate": {
479                "id": 1,
480                "url": "https://patchwork.ozlabs.org/api/1.2/users/1/",
481                "username": "jk",
482                "first_name": "Jeremy",
483                "last_name": "Kerr",
484                "email": "jk@ozlabs.org"
485            },
486            "mbox": "https://patchwork.ozlabs.org/project/cbe-oss-dev/patch/
487                200809050416.27831.adetsch@br.ibm.com/mbox/",
488            "series": [],
489            "comments": "https://patchwork.ozlabs.org/api/patches/185/
490                comments/",
491            "check": "pending",
492            "checks": "https://patchwork.ozlabs.org/api/patches/185/checks/",
493            "tags": {},
494            "related": [],
495            "headers": {...}
496            "content": "We currently have a race when scheduling a context
497                after we have found a runnable context in spusched_tick, the
498                context may have been scheduled by spu_activate().
499
500                This may result in a panic if we try to unschedule a context
501                been freed in the meantime.
502
503                This change exits spu_schedule() if the context has already
504                scheduled, so we don't end up scheduling it twice.
505
506                Signed-off-by: Andre Detsch <adetsch@br.ibm.com>",
507            "diff": '''Index: spufs/arch/powerpc/platforms/cell/spufs/sched.c
508                =======================================================
509                --- spufs.orig/arch/powerpc/platforms/cell/spufs/sched.c
510                +++ spufs/arch/powerpc/platforms/cell/spufs/sched.c
511                @@ -727,7 +727,8 @@ static void spu_schedule(struct spu *spu
512                 \t/* not a candidate for interruptible because it's called
513                 \t   from the scheduler thread or from spu_deactivate */
514                 \tmutex_lock(&ctx->state_mutex);
515                -\t__spu_schedule(spu, ctx);
516                +\tif (ctx->state == SPU_STATE_SAVED)
517                +\t\t__spu_schedule(spu, ctx);
518                 \tspu_release(ctx);
519                 }
520                '''
521            "prefixes": ["3/3", ...]
522        """
523        return await self._request(client, f'patches/{patch_id}/')
524
525    async def _get_patch_comments(self, client, patch_id):
526        """Read comments about a patch
527
528        Args:
529            client (aiohttp.ClientSession): Session to use
530            patch_id (str): Patchwork patch ID
531
532        Returns: list of dict: list of comments:
533            id (int): series ID unique across patchwork instance, e.g. 3331924
534            web_url (str): Full URL, e.g.
535                'https://patchwork.ozlabs.org/comment/3331924/'
536            msgid (str): Message ID, e.g.
537                '<d2526c98-8198-4b8b-ab10-20bda0151da1@gmx.de>'
538            list_archive_url: (unknown?)
539            date (str): Date, e.g. '2024-06-20T13:38:03'
540            subject (str): email subject, e.g. 'Re: [PATCH 3/5] buildman:
541                Support building within a Python venv'
542            date (str): Date, e.g. '2017-08-27T08:00:51'
543            submitter (dict): id, url, name, email, e.g.:
544                "id": 61270,
545                "url": "https://patchwork.ozlabs.org/api/people/61270/",
546                "name": "Heinrich Schuchardt",
547                "email": "xypron.glpk@gmx.de"
548            content (str): Content of email, e.g. 'On 20.06.24 15:19,
549                Simon Glass wrote:
550                >...'
551            headers: dict: email headers, see get_cover() for an example
552        """
553        return await self._request(client, f'patches/{patch_id}/comments/')
554
555    async def get_cover(self, client, cover_id):
556        """Read information about a cover letter
557
558        Args:
559            client (aiohttp.ClientSession): Session to use
560            cover_id (int): Patchwork cover-letter ID
561
562        Returns: dict containing patchwork's cover-letter information:
563            id (int): series ID unique across patchwork instance, e.g. 3
564            url (str): Full URL, e.g. https://patchwork.ozlabs.org/project/uboot/list/?series=3
565            project (dict): project information (id, url, name, link_name,
566                list_id, list_email, etc.
567            url (str): Full URL, e.g. 'https://patchwork.ozlabs.org/api/1.2/covers/2054866/'
568            web_url (str): Full URL, e.g. 'https://patchwork.ozlabs.org/project/uboot/cover/20250304130947.109799-1-sjg@chromium.org/'
569            project (dict): project information (id, url, name, link_name,
570                list_id, list_email, etc.
571            msgid (str): Message ID, e.g. '20250304130947.109799-1-sjg@chromium.org>'
572            list_archive_url (?)
573            date (str): Date, e.g. '2017-08-27T08:00:51'
574            name (str): Series name, e.g. '[U-Boot] moveconfig: fix error'
575            submitter (dict): id, url, name, email, e.g.:
576                "id": 6170,
577                "url": "https://patchwork.ozlabs.org/api/1.2/people/6170/",
578                "name": "Simon Glass",
579                "email": "sjg@chromium.org"
580            mbox (str): URL to mailbox, e.g. 'https://patchwork.ozlabs.org/project/uboot/cover/20250304130947.109799-1-sjg@chromium.org/mbox/'
581            series (list of dict) each e.g.:
582                "id": 446956,
583                "url": "https://patchwork.ozlabs.org/api/1.2/series/446956/",
584                "web_url": "https://patchwork.ozlabs.org/project/uboot/list/?series=446956",
585                "date": "2025-03-04T13:09:37",
586                "name": "binman: Check code-coverage requirements",
587                "version": 1,
588                "mbox": "https://patchwork.ozlabs.org/series/446956/mbox/"
589            comments: Web URL to comments: 'https://patchwork.ozlabs.org/api/covers/2054866/comments/'
590            headers: dict: e.g.:
591                "Return-Path": "<u-boot-bounces@lists.denx.de>",
592                "X-Original-To": "incoming@patchwork.ozlabs.org",
593                "Delivered-To": "patchwork-incoming@legolas.ozlabs.org",
594                "Authentication-Results": [
595                    "legolas.ozlabs.org;
596\tdkim=pass (1024-bit key;
597 unprotected) header.d=chromium.org header.i=@chromium.org header.a=rsa-sha256
598 header.s=google header.b=dG8yqtoK;
599\tdkim-atps=neutral",
600                    "legolas.ozlabs.org;
601 spf=pass (sender SPF authorized) smtp.mailfrom=lists.denx.de
602 (client-ip=85.214.62.61; helo=phobos.denx.de;
603 envelope-from=u-boot-bounces@lists.denx.de; receiver=patchwork.ozlabs.org)",
604                    "phobos.denx.de;
605 dmarc=pass (p=none dis=none) header.from=chromium.org",
606                    "phobos.denx.de;
607 spf=pass smtp.mailfrom=u-boot-bounces@lists.denx.de",
608                    "phobos.denx.de;
609\tdkim=pass (1024-bit key;
610 unprotected) header.d=chromium.org header.i=@chromium.org
611 header.b=\"dG8yqtoK\";
612\tdkim-atps=neutral",
613                    "phobos.denx.de;
614 dmarc=pass (p=none dis=none) header.from=chromium.org",
615                    "phobos.denx.de;
616 spf=pass smtp.mailfrom=sjg@chromium.org"
617                ],
618                "Received": [
619                    "from phobos.denx.de (phobos.denx.de [85.214.62.61])
620\t(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
621\t key-exchange X25519 server-signature ECDSA (secp384r1))
622\t(No client certificate requested)
623\tby legolas.ozlabs.org (Postfix) with ESMTPS id 4Z6bd50jLhz1yD0
624\tfor <incoming@patchwork.ozlabs.org>; Wed,  5 Mar 2025 00:10:00 +1100 (AEDT)",
625                    "from h2850616.stratoserver.net (localhost [IPv6:::1])
626\tby phobos.denx.de (Postfix) with ESMTP id 434E88144A;
627\tTue,  4 Mar 2025 14:09:58 +0100 (CET)",
628                    "by phobos.denx.de (Postfix, from userid 109)
629 id 8CBF98144A; Tue,  4 Mar 2025 14:09:57 +0100 (CET)",
630                    "from mail-io1-xd2e.google.com (mail-io1-xd2e.google.com
631 [IPv6:2607:f8b0:4864:20::d2e])
632 (using TLSv1.3 with cipher TLS_AES_128_GCM_SHA256 (128/128 bits))
633 (No client certificate requested)
634 by phobos.denx.de (Postfix) with ESMTPS id 48AE281426
635 for <u-boot@lists.denx.de>; Tue,  4 Mar 2025 14:09:55 +0100 (CET)",
636                    "by mail-io1-xd2e.google.com with SMTP id
637 ca18e2360f4ac-85ae33109f6so128326139f.2
638 for <u-boot@lists.denx.de>; Tue, 04 Mar 2025 05:09:55 -0800 (PST)",
639                    "from chromium.org (c-73-203-119-151.hsd1.co.comcast.net.
640 [73.203.119.151]) by smtp.gmail.com with ESMTPSA id
641 ca18e2360f4ac-858753cd304sm287383839f.33.2025.03.04.05.09.49
642 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
643 Tue, 04 Mar 2025 05:09:50 -0800 (PST)"
644                ],
645                "X-Spam-Checker-Version": "SpamAssassin 3.4.2 (2018-09-13) on phobos.denx.de",
646                "X-Spam-Level": "",
647                "X-Spam-Status": "No, score=-2.1 required=5.0 tests=BAYES_00,DKIMWL_WL_HIGH,
648 DKIM_SIGNED,DKIM_VALID,DKIM_VALID_AU,DKIM_VALID_EF,
649 RCVD_IN_DNSWL_BLOCKED,SPF_HELO_NONE,SPF_PASS autolearn=ham
650 autolearn_force=no version=3.4.2",
651                "DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed;
652 d=chromium.org; s=google; t=1741093792; x=1741698592; darn=lists.denx.de;
653 h=content-transfer-encoding:mime-version:message-id:date:subject:cc
654 :to:from:from:to:cc:subject:date:message-id:reply-to;
655 bh=B2zsLws430/BEZfatNjeaNnrcxmYUstVjp1pSXgNQjc=;
656 b=dG8yqtoKpSy15RHagnPcppzR8KbFCRXa2OBwXfwGoyN6M15tOJsUu2tpCdBFYiL5Mk
657 hQz5iDLV8p0Bs+fP4XtNEx7KeYfTZhiqcRFvdCLwYtGray/IHtOZaNoHLajrstic/OgE
658 01ymu6gOEboU32eQ8uC8pdCYQ4UCkfKJwmiiU=",
659                "X-Google-DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed;
660 d=1e100.net; s=20230601; t=1741093792; x=1741698592;
661 h=content-transfer-encoding:mime-version:message-id:date:subject:cc
662 :to:from:x-gm-message-state:from:to:cc:subject:date:message-id
663 :reply-to;
664 bh=B2zsLws430/BEZfatNjeaNnrcxmYUstVjp1pSXgNQjc=;
665 b=eihzJf4i9gin9usvz4hnAvvbLV9/yB7hGPpwwW/amgnPUyWCeQstgvGL7WDLYYnukH
666 161p4mt7+cCj7Hao/jSPvVZeuKiBNPkS4YCuP3QjXfdk2ziQ9IjloVmGarWZUOlYJ5iQ
667 dZnxypUkuFfLcEDSwUmRO1dvLi3nH8PDlae3yT2H87LeHaxhXWdzHxQdPc86rkYyCqCr
668 qBC2CTS31jqSuiaI+7qB3glvbJbSEXkunz0iDewTJDvZfmuloxTipWUjRJ1mg9UJcZt5
669 9xIuTq1n9aYf1RcQlrEOQhdBAQ0/IJgvmZtzPZi9L+ppBva1ER/xm06nMA7GEUtyGwun
670 c6pA==",
671                "X-Gm-Message-State": "AOJu0Yybx3b1+yClf/IfIbQd9u8sxzK9ixPP2HimXF/dGZfSiS7Cb+O5
672 WrAkvtp7m3KPM/Mpv0sSZ5qrfTnKnb3WZyv6Oe5Q1iUjAftGNwbSxob5eJ/0y3cgrTdzE4sIWPE
673 =",
674                "X-Gm-Gg": "ASbGncu5gtgpXEPGrpbTRJulqFrFj1YPAAmKk4MiXA8/3J1A+25F0Uug2KeFUrZEjkG
675 KMdPg/C7e2emIvfM+Jl+mKv0ITBvhbyNCyY1q2U1s1cayZF05coZ9ewzGxXJGiEqLMG69uBmmIi
676 rBEvCnkXS+HVZobDQMtOsezpc+Ju8JRA7+y1R0WIlutl1mQARct6p0zTkuZp75QyB6dm/d0KYgd
677 iux/t/f0HC2CxstQlTlJYzKL6UJgkB5/UorY1lW/0NDRS6P1iemPQ7I3EPLJO8tM5ZrpJE7qgNP
678 xy0jXbUv44c48qJ1VszfY5USB8fRG7nwUYxNu6N1PXv9xWbl+z2xL68qNYUrFlHsB8ILTXAyzyr
679 Cdj+Sxg==",
680                "X-Google-Smtp-Source": "
681 AGHT+IFeVk5D4YEfJgPxOfg3ikO6Q7IhaDzABGkAPI6HA0ubK85OPhUHK08gV7enBQ8OdoE/ttqEjw==",
682                "X-Received": "by 2002:a05:6602:640f:b0:855:63c8:abb5 with SMTP id
683 ca18e2360f4ac-85881fdba3amr1839428939f.13.1741093792636;
684 Tue, 04 Mar 2025 05:09:52 -0800 (PST)",
685                "From": "Simon Glass <sjg@chromium.org>",
686                "To": "U-Boot Mailing List <u-boot@lists.denx.de>",
687                "Cc": "Simon Glass <sjg@chromium.org>, Alexander Kochetkov <al.kochet@gmail.com>,
688 Alper Nebi Yasak <alpernebiyasak@gmail.com>,
689 Brandon Maier <brandon.maier@collins.com>,
690 Jerome Forissier <jerome.forissier@linaro.org>,
691 Jiaxun Yang <jiaxun.yang@flygoat.com>,
692 Neha Malcom Francis <n-francis@ti.com>,
693 Patrick Rudolph <patrick.rudolph@9elements.com>,
694 Paul HENRYS <paul.henrys_ext@softathome.com>, Peng Fan <peng.fan@nxp.com>,
695 Philippe Reynes <philippe.reynes@softathome.com>,
696 Stefan Herbrechtsmeier <stefan.herbrechtsmeier@weidmueller.com>,
697 Tom Rini <trini@konsulko.com>",
698                "Subject": "[PATCH 0/7] binman: Check code-coverage requirements",
699                "Date": "Tue,  4 Mar 2025 06:09:37 -0700",
700                "Message-ID": "<20250304130947.109799-1-sjg@chromium.org>",
701                "X-Mailer": "git-send-email 2.43.0",
702                "MIME-Version": "1.0",
703                "Content-Transfer-Encoding": "8bit",
704                "X-BeenThere": "u-boot@lists.denx.de",
705                "X-Mailman-Version": "2.1.39",
706                "Precedence": "list",
707                "List-Id": "U-Boot discussion <u-boot.lists.denx.de>",
708                "List-Unsubscribe": "<https://lists.denx.de/options/u-boot>,
709 <mailto:u-boot-request@lists.denx.de?subject=unsubscribe>",
710                "List-Archive": "<https://lists.denx.de/pipermail/u-boot/>",
711                "List-Post": "<mailto:u-boot@lists.denx.de>",
712                "List-Help": "<mailto:u-boot-request@lists.denx.de?subject=help>",
713                "List-Subscribe": "<https://lists.denx.de/listinfo/u-boot>,
714 <mailto:u-boot-request@lists.denx.de?subject=subscribe>",
715                "Errors-To": "u-boot-bounces@lists.denx.de",
716                "Sender": "\"U-Boot\" <u-boot-bounces@lists.denx.de>",
717                "X-Virus-Scanned": "clamav-milter 0.103.8 at phobos.denx.de",
718                "X-Virus-Status": "Clean"
719            content (str): Email content, e.g. 'This series adds a cover-coverage check to CI for Binman. The iMX8 tests
720are still not completed,...'
721        """
722        async with aiohttp.ClientSession() as client:
723            return await self._request(client, f'covers/{cover_id}/')
724
725    async def get_cover_comments(self, client, cover_id):
726        """Read comments about a cover letter
727
728        Args:
729            client (aiohttp.ClientSession): Session to use
730            cover_id (str): Patchwork cover-letter ID
731
732        Returns: list of dict: list of comments, each:
733            id (int): series ID unique across patchwork instance, e.g. 3472068
734            web_url (str): Full URL, e.g. 'https://patchwork.ozlabs.org/comment/3472068/'
735            list_archive_url: (unknown?)
736
737            project (dict): project information (id, url, name, link_name,
738                list_id, list_email, etc.
739            url (str): Full URL, e.g. 'https://patchwork.ozlabs.org/api/1.2/covers/2054866/'
740            web_url (str): Full URL, e.g. 'https://patchwork.ozlabs.org/project/uboot/cover/20250304130947.109799-1-sjg@chromium.org/'
741            project (dict): project information (id, url, name, link_name,
742                list_id, list_email, etc.
743            date (str): Date, e.g. '2025-03-04T13:16:15'
744            subject (str): 'Re: [PATCH 0/7] binman: Check code-coverage requirements'
745            submitter (dict): id, url, name, email, e.g.:
746                "id": 6170,
747                "url": "https://patchwork.ozlabs.org/api/people/6170/",
748                "name": "Simon Glass",
749                "email": "sjg@chromium.org"
750            content (str): Email content, e.g. 'Hi,
751
752On Tue, 4 Mar 2025 at 06:09, Simon Glass <sjg@chromium.org> wrote:
753>
754> This '...
755            headers: dict: email headers, see get_cover() for an example
756        """
757        return await self._request(client, f'covers/{cover_id}/comments/')
758
759    async def get_series_url(self, link):
760        """Get the URL for a series
761
762        Args:
763            link (str): Patchwork series ID
764
765        Returns:
766            str: URL for the series page
767        """
768        return f'{self.url}/project/{self.link_name}/list/?series={link}&state=*&archive=both'
769
770    async def _get_patch_status(self, client, patch_id):
771        """Get the patch status
772
773        Args:
774            client (aiohttp.ClientSession): Session to use
775            patch_id (int): Patch ID to look up in patchwork
776
777        Return:
778            PATCH: Patch information
779
780        Requests:
781            1 for patch, 1 for patch comments
782        """
783        data = await self.get_patch(client, patch_id)
784        state = data['state']
785        comment_data = await self._get_patch_comments(client, patch_id)
786
787        return Patch(patch_id, state, data, comment_data)
788
789    async def get_series_cover(self, client, data):
790        """Get the cover information (including comments)
791
792        Args:
793            client (aiohttp.ClientSession): Session to use
794            data (dict): Return value from self.get_series()
795
796        Returns:
797            COVER object, or None if no cover letter
798        """
799        # Patchwork should always provide this, but use get() so that we don't
800        # have to provide it in our fake patchwork _fake_patchwork_cser()
801        cover = data.get('cover_letter')
802        cover_id = None
803        if cover:
804            cover_id = cover['id']
805            info = await self.get_cover_comments(client, cover_id)
806            cover = COVER(cover_id, len(info), cover['name'], info)
807        return cover
808
809    async def series_get_state(self, client, link, read_comments,
810                               read_cover_comments):
811        """Sync the series information against patchwork, to find patch status
812
813        Args:
814            client (aiohttp.ClientSession): Session to use
815            link (str): Patchwork series ID
816            read_comments (bool): True to read the comments on the patches
817            read_cover_comments (bool): True to read the comments on the cover
818                letter
819
820        Return: tuple:
821            COVER object, or None if none or not read_cover_comments
822            list of PATCH objects
823        """
824        data = await self.get_series(client, link)
825        patch_list = list(data['patches'])
826
827        count = len(patch_list)
828        patches = []
829        if read_comments:
830            # Returns a list of Patch objects
831            tasks = [self._get_patch_status(client, patch_list[i]['id'])
832                     for i in range(count)]
833
834            patch_status = await asyncio.gather(*tasks)
835            for patch_data, status in zip(patch_list, patch_status):
836                status.series_data = patch_data
837                patches.append(status)
838        else:
839            for i in range(count):
840                info = patch_list[i]
841                pat = Patch(info['id'], series_data=info)
842                pat.raw_subject = info['name']
843                patches.append(pat)
844        if self._show_progress:
845            terminal.print_clear()
846
847        if read_cover_comments:
848            cover = await self.get_series_cover(client, data)
849        else:
850            cover = None
851
852        return cover, patches
853