summaryrefslogtreecommitdiff
path: root/tools/patman/patchwork.py
blob: d485648e4674604b3d98b933e61cd5a142abf2c2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
# SPDX-License-Identifier: GPL-2.0+
#
# Copyright 2025 Simon Glass <sjg@chromium.org>
#
"""Provides a basic API for the patchwork server
"""

import asyncio
import re

import aiohttp
from collections import namedtuple

from u_boot_pylib import terminal

# Information passed to series_get_states()
# link (str): Patchwork link for series
# series_id (int): Series ID in database
# series_name (str): Series name
# version (int): Version number of series
# show_comments (bool): True to show comments
# show_cover_comments (bool): True to show cover-letter comments
STATE_REQ = namedtuple(
    'state_req',
    'link,series_id,series_name,version,show_comments,show_cover_comments')

# Responses from series_get_states()
# int: ser_ver ID number
# COVER: Cover-letter info
# list of Patch: Information on each patch in the series
# list of dict: patches, see get_series()['patches']
STATE_RESP = namedtuple('state_resp', 'svid,cover,patches,patch_list')

# Information about a cover-letter on patchwork
# id (int): Patchwork ID of cover letter
# state (str): Current state, e.g. 'accepted'
# num_comments (int): Number of comments
# name (str): Series name
# comments (list of dict): Comments
COVER = namedtuple('cover', 'id,num_comments,name,comments')

# Number of retries
RETRIES = 3

# Max concurrent request
MAX_CONCURRENT = 50

# Patches which are part of a multi-patch series are shown with a prefix like
# [prefix, version, sequence], for example '[RFC, v2, 3/5]'. All but the last
# part is optional. This decodes the string into groups. For single patches
# the [] part is not present:
# Groups: (ignore, ignore, ignore, prefix, version, sequence, subject)
RE_PATCH = re.compile(r'(\[(((.*),)?(.*),)?(.*)\]\s)?(.*)$')

# This decodes the sequence string into a patch number and patch count
RE_SEQ = re.compile(r'(\d+)/(\d+)')


class Patch(dict):
    """Models a patch in patchwork

    This class records information obtained from patchwork

    Some of this information comes from the 'Patch' column:

        [RFC,v2,1/3] dm: Driver and uclass changes for tiny-dm

    This shows the prefix, version, seq, count and subject.

    The other properties come from other columns in the display.

    Properties:
        pid (str): ID of the patch (typically an integer)
        seq (int): Sequence number within series (1=first) parsed from sequence
            string
        count (int): Number of patches in series, parsed from sequence string
        raw_subject (str): Entire subject line, e.g.
            "[1/2,v2] efi_loader: Sort header file ordering"
        prefix (str): Prefix string or None (e.g. 'RFC')
        version (str): Version string or None (e.g. 'v2')
        raw_subject (str): Raw patch subject
        subject (str): Patch subject with [..] part removed (same as commit
            subject)
        data (dict or None): Patch data:
    """
    def __init__(self, pid, state=None, data=None, comments=None,
                 series_data=None):
        super().__init__()
        self.id = pid  # Use 'id' to match what the Rest API provides
        self.seq = None
        self.count = None
        self.prefix = None
        self.version = None
        self.raw_subject = None
        self.subject = None
        self.state = state
        self.data = data
        self.comments = comments
        self.series_data = series_data
        self.name = None

    # These make us more like a dictionary
    def __setattr__(self, name, value):
        self[name] = value

    def __getattr__(self, name):
        return self[name]

    def __hash__(self):
        return hash(frozenset(self.items()))

    def __str__(self):
        return self.raw_subject

    def parse_subject(self, raw_subject):
        """Parse the subject of a patch into its component parts

        See RE_PATCH for details. The parsed info is placed into seq, count,
        prefix, version, subject

        Args:
            raw_subject (str): Subject string to parse

        Raises:
            ValueError: the subject cannot be parsed
        """
        self.raw_subject = raw_subject.strip()
        mat = RE_PATCH.search(raw_subject.strip())
        if not mat:
            raise ValueError(f"Cannot parse subject '{raw_subject}'")
        self.prefix, self.version, seq_info, self.subject = mat.groups()[3:]
        mat_seq = RE_SEQ.match(seq_info) if seq_info else False
        if mat_seq is None:
            self.version = seq_info
            seq_info = None
        if self.version and not self.version.startswith('v'):
            self.prefix = self.version
            self.version = None
        if seq_info:
            if mat_seq:
                self.seq = int(mat_seq.group(1))
                self.count = int(mat_seq.group(2))
        else:
            self.seq = 1
            self.count = 1


class Review:
    """Represents a single review email collected in Patchwork

    Patches can attract multiple reviews. Each consists of an author/date and
    a variable number of 'snippets', which are groups of quoted and unquoted
    text.
    """
    def __init__(self, meta, snippets):
        """Create new Review object

        Args:
            meta (str): Text containing review author and date
            snippets (list): List of snippets in th review, each a list of text
                lines
        """
        self.meta = ' : '.join([line for line in meta.splitlines() if line])
        self.snippets = snippets


class Patchwork:
    """Class to handle communication with patchwork
    """
    def __init__(self, url, show_progress=True, single_thread=False):
        """Set up a new patchwork handler

        Args:
            url (str): URL of patchwork server, e.g.
               'https://patchwork.ozlabs.org'
        """
        self.url = url
        self.fake_request = None
        self.proj_id = None
        self.link_name = None
        self._show_progress = show_progress
        self.semaphore = asyncio.Semaphore(
            1 if single_thread else MAX_CONCURRENT)
        self.request_count = 0

    async def _request(self, client, subpath):
        """Call the patchwork API and return the result as JSON

        Args:
            client (aiohttp.ClientSession): Session to use
            subpath (str): URL subpath to use

        Returns:
            dict: Json result

        Raises:
            ValueError: the URL could not be read
        """
        # print('subpath', subpath)
        self.request_count += 1
        if self.fake_request:
            return self.fake_request(subpath)

        full_url = f'{self.url}/api/1.2/{subpath}'
        async with self.semaphore:
            # print('full_url', full_url)
            for i in range(RETRIES + 1):
                try:
                    async with client.get(full_url) as response:
                        if response.status != 200:
                            raise ValueError(
                                f"Could not read URL '{full_url}'")
                        result = await response.json()
                        # print('- done', full_url)
                        return result
                    break
                except aiohttp.client_exceptions.ServerDisconnectedError:
                    if i == RETRIES:
                        raise

    @staticmethod
    def for_testing(func):
        """Get an instance to use for testing

        Args:
            func (function): Function to call to handle requests. The function
                is passed a URL and is expected to return a dict with the
                resulting data

        Returns:
            Patchwork: testing instance
        """
        pwork = Patchwork(None, show_progress=False)
        pwork.fake_request = func
        return pwork

    class _Stats:
        def __init__(self, parent):
            self.parent = parent
            self.request_count = 0

        def __enter__(self):
            return self

        def __exit__(self, exc_type, exc_val, exc_tb):
            self.request_count = self.parent.request_count

    def collect_stats(self):
        """Context manager to count requests across a range of patchwork calls

        Usage:
            pwork = Patchwork(...)
            with pwork.count_requests() as counter:
                pwork.something()
            print(f'{counter.count} requests')
        """
        self.request_count = 0
        return self._Stats(self)

    async def get_projects(self):
        """Get a list of projects on the server

        Returns:
            list of dict, one for each project
                'name' (str): Project name, e.g. 'U-Boot'
                'id' (int): Project ID, e.g. 9
                'link_name' (str): Project's link-name, e.g. 'uboot'
        """
        async with aiohttp.ClientSession() as client:
            return await self._request(client, 'projects/')

    async def _query_series(self, client, desc):
        """Query series by name

        Args:
            client (aiohttp.ClientSession): Session to use
            desc: String to search for

        Return:
            list of series matches, each a dict, see get_series()
        """
        query = desc.replace(' ', '+')
        return await self._request(
            client, f'series/?project={self.proj_id}&q={query}')

    async def _find_series(self, client, svid, ser_id, version, ser):
        """Find a series on the server

        Args:
            client (aiohttp.ClientSession): Session to use
            svid (int): ser_ver ID
            ser_id (int): series ID
            version (int): Version number to search for
            ser (Series): Contains description (cover-letter title)

        Returns:
            tuple:
                int: ser_ver ID (as passed in)
                int: series ID (as passed in)
                str: Series link, or None if not found
                list of dict, or None if found
                    each dict is the server result from a possible series
        """
        desc = ser.desc
        name_found = []

        # Do a series query on the description
        res = await self._query_series(client, desc)
        for pws in res:
            if pws['name'] == desc:
                if int(pws['version']) == version:
                    return svid, ser_id, pws['id'], None
                name_found.append(pws)

        # When there is no cover letter, patchwork uses the first patch as the
        # series name
        cmt = ser.commits[0]

        res = await self._query_series(client, cmt.subject)
        for pws in res:
            patch = Patch(0)
            patch.parse_subject(pws['name'])
            if patch.subject == cmt.subject:
                if int(pws['version']) == version:
                    return svid, ser_id, pws['id'], None
                name_found.append(pws)

        return svid, ser_id, None, name_found or res

    async def find_series(self, ser, version):
        """Find a series based on its description and version

        Args:
            ser (Series): Contains description (cover-letter title)
            version (int): Version number

        Return: tuple:
            tuple:
                str: Series ID, or None if not found
                list of dict, or None if found
                    each dict is the server result from a possible series
            int: number of server requests done
        """
        async with aiohttp.ClientSession() as client:
            # We don't know the svid and it isn't needed, so use -1
            _, _, link, options = await self._find_series(client, -1, -1,
                                                          version, ser)
        return link, options

    async def find_series_list(self, to_find):
        """Find the link for each series in a list

        Args:
            to_find (dict of svids to sync):
                key (int): ser_ver ID
                value (tuple):
                    int: Series ID
                    int: Series version
                    str: Series link
                    str: Series description

        Return: tuple:
            list of tuple, one for each item in to_find:
                int: ser_ver_ID
                int: series ID
                int: Series version
                str: Series link, or None if not found
                list of dict, or None if found
                    each dict is the server result from a possible series
            int: number of server requests done
        """
        self.request_count = 0
        async with aiohttp.ClientSession() as client:
            tasks = [asyncio.create_task(
                self._find_series(client, svid, ser_id, version, desc))
                for svid, (ser_id, version, link, desc) in to_find.items()]
            results = await asyncio.gather(*tasks)

        return results, self.request_count

    def project_set(self, project_id, link_name):
        """Set the project ID

        The patchwork server has multiple projects. This allows the ID and
        link_name of the relevant project to be selected

        This function is used for testing

        Args:
            project_id (int): Project ID to use, e.g. 6
            link_name (str): Name to use for project URL links, e.g. 'uboot'
        """
        self.proj_id = project_id
        self.link_name = link_name

    async def get_series(self, client, link):
        """Read information about a series

        Args:
            client (aiohttp.ClientSession): Session to use
            link (str): Patchwork series ID

        Returns: dict containing patchwork's series information
            id (int): series ID unique across patchwork instance, e.g. 3
            url (str): Full URL, e.g.
                'https://patchwork.ozlabs.org/api/1.2/series/3/'
            web_url (str): Full URL, e.g.
                'https://patchwork.ozlabs.org/project/uboot/list/?series=3
            project (dict): project information (id, url, name, link_name,
                list_id, list_email, etc.
            name (str): Series name, e.g. '[U-Boot] moveconfig: fix error'
            date (str): Date, e.g. '2017-08-27T08:00:51'
            submitter (dict): id, url, name, email, e.g.:
                "id": 6125,
                "url": "https://patchwork.ozlabs.org/api/1.2/people/6125/",
                "name": "Chris Packham",
                "email": "judge.packham@gmail.com"
            version (int): Version number
            total (int): Total number of patches based on subject
            received_total (int): Total patches received by patchwork
            received_all (bool): True if all patches were received
            mbox (str): URL of mailbox, e.g.
                'https://patchwork.ozlabs.org/series/3/mbox/'
            cover_letter (dict) or None, e.g.:
                "id": 806215,
                "url": "https://patchwork.ozlabs.org/api/1.2/covers/806215/",
                "web_url": "https://patchwork.ozlabs.org/project/uboot/cover/
                    20170827094411.8583-1-judge.packham@gmail.com/",
                "msgid": "<20170827094411.8583-1-judge.packham@gmail.com>",
                "list_archive_url": null,
                "date": "2017-08-27T09:44:07",
                "name": "[U-Boot,v2,0/4] usb: net: Migrate USB Ethernet",
                "mbox": "https://patchwork.ozlabs.org/project/uboot/cover/
                    20170827094411.8583-1-judge.packham@gmail.com/mbox/"
            patches (list of dict), each e.g.:
                "id": 806202,
                "url": "https://patchwork.ozlabs.org/api/1.2/patches/806202/",
                "web_url": "https://patchwork.ozlabs.org/project/uboot/patch/
                    20170827080051.816-1-judge.packham@gmail.com/",
                "msgid": "<20170827080051.816-1-judge.packham@gmail.com>",
                "list_archive_url": null,
                "date": "2017-08-27T08:00:51",
                "name": "[U-Boot] moveconfig: fix error message do_autoconf()",
                "mbox": "https://patchwork.ozlabs.org/project/uboot/patch/
                    20170827080051.816-1-judge.packham@gmail.com/mbox/"
        """
        return await self._request(client, f'series/{link}/')

    async def get_patch(self, client, patch_id):
        """Read information about a patch

        Args:
            client (aiohttp.ClientSession): Session to use
            patch_id (str): Patchwork patch ID

        Returns: dict containing patchwork's patch information
            "id": 185,
            "url": "https://patchwork.ozlabs.org/api/1.2/patches/185/",
            "web_url": "https://patchwork.ozlabs.org/project/cbe-oss-dev/patch/
                200809050416.27831.adetsch@br.ibm.com/",
            project (dict): project information (id, url, name, link_name,
                    list_id, list_email, etc.
            "msgid": "<200809050416.27831.adetsch@br.ibm.com>",
            "list_archive_url": null,
            "date": "2008-09-05T07:16:27",
            "name": "powerpc/spufs: Fix possible scheduling of a context",
            "commit_ref": "b2e601d14deb2083e2a537b47869ab3895d23a28",
            "pull_url": null,
            "state": "accepted",
            "archived": false,
            "hash": "bc1c0b80d7cff66c0d1e5f3f8f4d10eb36176f0d",
            "submitter": {
                "id": 93,
                "url": "https://patchwork.ozlabs.org/api/1.2/people/93/",
                "name": "Andre Detsch",
                "email": "adetsch@br.ibm.com"
            },
            "delegate": {
                "id": 1,
                "url": "https://patchwork.ozlabs.org/api/1.2/users/1/",
                "username": "jk",
                "first_name": "Jeremy",
                "last_name": "Kerr",
                "email": "jk@ozlabs.org"
            },
            "mbox": "https://patchwork.ozlabs.org/project/cbe-oss-dev/patch/
                200809050416.27831.adetsch@br.ibm.com/mbox/",
            "series": [],
            "comments": "https://patchwork.ozlabs.org/api/patches/185/
                comments/",
            "check": "pending",
            "checks": "https://patchwork.ozlabs.org/api/patches/185/checks/",
            "tags": {},
            "related": [],
            "headers": {...}
            "content": "We currently have a race when scheduling a context
                after we have found a runnable context in spusched_tick, the
                context may have been scheduled by spu_activate().

                This may result in a panic if we try to unschedule a context
                been freed in the meantime.

                This change exits spu_schedule() if the context has already
                scheduled, so we don't end up scheduling it twice.

                Signed-off-by: Andre Detsch <adetsch@br.ibm.com>",
            "diff": '''Index: spufs/arch/powerpc/platforms/cell/spufs/sched.c
                =======================================================
                --- spufs.orig/arch/powerpc/platforms/cell/spufs/sched.c
                +++ spufs/arch/powerpc/platforms/cell/spufs/sched.c
                @@ -727,7 +727,8 @@ static void spu_schedule(struct spu *spu
                 \t/* not a candidate for interruptible because it's called
                 \t   from the scheduler thread or from spu_deactivate */
                 \tmutex_lock(&ctx->state_mutex);
                -\t__spu_schedule(spu, ctx);
                +\tif (ctx->state == SPU_STATE_SAVED)
                +\t\t__spu_schedule(spu, ctx);
                 \tspu_release(ctx);
                 }
                '''
            "prefixes": ["3/3", ...]
        """
        return await self._request(client, f'patches/{patch_id}/')

    async def _get_patch_comments(self, client, patch_id):
        """Read comments about a patch

        Args:
            client (aiohttp.ClientSession): Session to use
            patch_id (str): Patchwork patch ID

        Returns: list of dict: list of comments:
            id (int): series ID unique across patchwork instance, e.g. 3331924
            web_url (str): Full URL, e.g.
                'https://patchwork.ozlabs.org/comment/3331924/'
            msgid (str): Message ID, e.g.
                '<d2526c98-8198-4b8b-ab10-20bda0151da1@gmx.de>'
            list_archive_url: (unknown?)
            date (str): Date, e.g. '2024-06-20T13:38:03'
            subject (str): email subject, e.g. 'Re: [PATCH 3/5] buildman:
                Support building within a Python venv'
            date (str): Date, e.g. '2017-08-27T08:00:51'
            submitter (dict): id, url, name, email, e.g.:
                "id": 61270,
                "url": "https://patchwork.ozlabs.org/api/people/61270/",
                "name": "Heinrich Schuchardt",
                "email": "xypron.glpk@gmx.de"
            content (str): Content of email, e.g. 'On 20.06.24 15:19,
                Simon Glass wrote:
                >...'
            headers: dict: email headers, see get_cover() for an example
        """
        return await self._request(client, f'patches/{patch_id}/comments/')

    async def get_cover(self, client, cover_id):
        """Read information about a cover letter

        Args:
            client (aiohttp.ClientSession): Session to use
            cover_id (int): Patchwork cover-letter ID

        Returns: dict containing patchwork's cover-letter information:
            id (int): series ID unique across patchwork instance, e.g. 3
            url (str): Full URL, e.g. https://patchwork.ozlabs.org/project/uboot/list/?series=3
            project (dict): project information (id, url, name, link_name,
                list_id, list_email, etc.
            url (str): Full URL, e.g. 'https://patchwork.ozlabs.org/api/1.2/covers/2054866/'
            web_url (str): Full URL, e.g. 'https://patchwork.ozlabs.org/project/uboot/cover/20250304130947.109799-1-sjg@chromium.org/'
            project (dict): project information (id, url, name, link_name,
                list_id, list_email, etc.
            msgid (str): Message ID, e.g. '20250304130947.109799-1-sjg@chromium.org>'
            list_archive_url (?)
            date (str): Date, e.g. '2017-08-27T08:00:51'
            name (str): Series name, e.g. '[U-Boot] moveconfig: fix error'
            submitter (dict): id, url, name, email, e.g.:
                "id": 6170,
                "url": "https://patchwork.ozlabs.org/api/1.2/people/6170/",
                "name": "Simon Glass",
                "email": "sjg@chromium.org"
            mbox (str): URL to mailbox, e.g. 'https://patchwork.ozlabs.org/project/uboot/cover/20250304130947.109799-1-sjg@chromium.org/mbox/'
            series (list of dict) each e.g.:
                "id": 446956,
                "url": "https://patchwork.ozlabs.org/api/1.2/series/446956/",
                "web_url": "https://patchwork.ozlabs.org/project/uboot/list/?series=446956",
                "date": "2025-03-04T13:09:37",
                "name": "binman: Check code-coverage requirements",
                "version": 1,
                "mbox": "https://patchwork.ozlabs.org/series/446956/mbox/"
            comments: Web URL to comments: 'https://patchwork.ozlabs.org/api/covers/2054866/comments/'
            headers: dict: e.g.:
                "Return-Path": "<u-boot-bounces@lists.denx.de>",
                "X-Original-To": "incoming@patchwork.ozlabs.org",
                "Delivered-To": "patchwork-incoming@legolas.ozlabs.org",
                "Authentication-Results": [
                    "legolas.ozlabs.org;
\tdkim=pass (1024-bit key;
 unprotected) header.d=chromium.org header.i=@chromium.org header.a=rsa-sha256
 header.s=google header.b=dG8yqtoK;
\tdkim-atps=neutral",
                    "legolas.ozlabs.org;
 spf=pass (sender SPF authorized) smtp.mailfrom=lists.denx.de
 (client-ip=85.214.62.61; helo=phobos.denx.de;
 envelope-from=u-boot-bounces@lists.denx.de; receiver=patchwork.ozlabs.org)",
                    "phobos.denx.de;
 dmarc=pass (p=none dis=none) header.from=chromium.org",
                    "phobos.denx.de;
 spf=pass smtp.mailfrom=u-boot-bounces@lists.denx.de",
                    "phobos.denx.de;
\tdkim=pass (1024-bit key;
 unprotected) header.d=chromium.org header.i=@chromium.org
 header.b=\"dG8yqtoK\";
\tdkim-atps=neutral",
                    "phobos.denx.de;
 dmarc=pass (p=none dis=none) header.from=chromium.org",
                    "phobos.denx.de;
 spf=pass smtp.mailfrom=sjg@chromium.org"
                ],
                "Received": [
                    "from phobos.denx.de (phobos.denx.de [85.214.62.61])
\t(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
\t key-exchange X25519 server-signature ECDSA (secp384r1))
\t(No client certificate requested)
\tby legolas.ozlabs.org (Postfix) with ESMTPS id 4Z6bd50jLhz1yD0
\tfor <incoming@patchwork.ozlabs.org>; Wed,  5 Mar 2025 00:10:00 +1100 (AEDT)",
                    "from h2850616.stratoserver.net (localhost [IPv6:::1])
\tby phobos.denx.de (Postfix) with ESMTP id 434E88144A;
\tTue,  4 Mar 2025 14:09:58 +0100 (CET)",
                    "by phobos.denx.de (Postfix, from userid 109)
 id 8CBF98144A; Tue,  4 Mar 2025 14:09:57 +0100 (CET)",
                    "from mail-io1-xd2e.google.com (mail-io1-xd2e.google.com
 [IPv6:2607:f8b0:4864:20::d2e])
 (using TLSv1.3 with cipher TLS_AES_128_GCM_SHA256 (128/128 bits))
 (No client certificate requested)
 by phobos.denx.de (Postfix) with ESMTPS id 48AE281426
 for <u-boot@lists.denx.de>; Tue,  4 Mar 2025 14:09:55 +0100 (CET)",
                    "by mail-io1-xd2e.google.com with SMTP id
 ca18e2360f4ac-85ae33109f6so128326139f.2
 for <u-boot@lists.denx.de>; Tue, 04 Mar 2025 05:09:55 -0800 (PST)",
                    "from chromium.org (c-73-203-119-151.hsd1.co.comcast.net.
 [73.203.119.151]) by smtp.gmail.com with ESMTPSA id
 ca18e2360f4ac-858753cd304sm287383839f.33.2025.03.04.05.09.49
 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
 Tue, 04 Mar 2025 05:09:50 -0800 (PST)"
                ],
                "X-Spam-Checker-Version": "SpamAssassin 3.4.2 (2018-09-13) on phobos.denx.de",
                "X-Spam-Level": "",
                "X-Spam-Status": "No, score=-2.1 required=5.0 tests=BAYES_00,DKIMWL_WL_HIGH,
 DKIM_SIGNED,DKIM_VALID,DKIM_VALID_AU,DKIM_VALID_EF,
 RCVD_IN_DNSWL_BLOCKED,SPF_HELO_NONE,SPF_PASS autolearn=ham
 autolearn_force=no version=3.4.2",
                "DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed;
 d=chromium.org; s=google; t=1741093792; x=1741698592; darn=lists.denx.de;
 h=content-transfer-encoding:mime-version:message-id:date:subject:cc
 :to:from:from:to:cc:subject:date:message-id:reply-to;
 bh=B2zsLws430/BEZfatNjeaNnrcxmYUstVjp1pSXgNQjc=;
 b=dG8yqtoKpSy15RHagnPcppzR8KbFCRXa2OBwXfwGoyN6M15tOJsUu2tpCdBFYiL5Mk
 hQz5iDLV8p0Bs+fP4XtNEx7KeYfTZhiqcRFvdCLwYtGray/IHtOZaNoHLajrstic/OgE
 01ymu6gOEboU32eQ8uC8pdCYQ4UCkfKJwmiiU=",
                "X-Google-DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed;
 d=1e100.net; s=20230601; t=1741093792; x=1741698592;
 h=content-transfer-encoding:mime-version:message-id:date:subject:cc
 :to:from:x-gm-message-state:from:to:cc:subject:date:message-id
 :reply-to;
 bh=B2zsLws430/BEZfatNjeaNnrcxmYUstVjp1pSXgNQjc=;
 b=eihzJf4i9gin9usvz4hnAvvbLV9/yB7hGPpwwW/amgnPUyWCeQstgvGL7WDLYYnukH
 161p4mt7+cCj7Hao/jSPvVZeuKiBNPkS4YCuP3QjXfdk2ziQ9IjloVmGarWZUOlYJ5iQ
 dZnxypUkuFfLcEDSwUmRO1dvLi3nH8PDlae3yT2H87LeHaxhXWdzHxQdPc86rkYyCqCr
 qBC2CTS31jqSuiaI+7qB3glvbJbSEXkunz0iDewTJDvZfmuloxTipWUjRJ1mg9UJcZt5
 9xIuTq1n9aYf1RcQlrEOQhdBAQ0/IJgvmZtzPZi9L+ppBva1ER/xm06nMA7GEUtyGwun
 c6pA==",
                "X-Gm-Message-State": "AOJu0Yybx3b1+yClf/IfIbQd9u8sxzK9ixPP2HimXF/dGZfSiS7Cb+O5
 WrAkvtp7m3KPM/Mpv0sSZ5qrfTnKnb3WZyv6Oe5Q1iUjAftGNwbSxob5eJ/0y3cgrTdzE4sIWPE
 =",
                "X-Gm-Gg": "ASbGncu5gtgpXEPGrpbTRJulqFrFj1YPAAmKk4MiXA8/3J1A+25F0Uug2KeFUrZEjkG
 KMdPg/C7e2emIvfM+Jl+mKv0ITBvhbyNCyY1q2U1s1cayZF05coZ9ewzGxXJGiEqLMG69uBmmIi
 rBEvCnkXS+HVZobDQMtOsezpc+Ju8JRA7+y1R0WIlutl1mQARct6p0zTkuZp75QyB6dm/d0KYgd
 iux/t/f0HC2CxstQlTlJYzKL6UJgkB5/UorY1lW/0NDRS6P1iemPQ7I3EPLJO8tM5ZrpJE7qgNP
 xy0jXbUv44c48qJ1VszfY5USB8fRG7nwUYxNu6N1PXv9xWbl+z2xL68qNYUrFlHsB8ILTXAyzyr
 Cdj+Sxg==",
                "X-Google-Smtp-Source": "
 AGHT+IFeVk5D4YEfJgPxOfg3ikO6Q7IhaDzABGkAPI6HA0ubK85OPhUHK08gV7enBQ8OdoE/ttqEjw==",
                "X-Received": "by 2002:a05:6602:640f:b0:855:63c8:abb5 with SMTP id
 ca18e2360f4ac-85881fdba3amr1839428939f.13.1741093792636;
 Tue, 04 Mar 2025 05:09:52 -0800 (PST)",
                "From": "Simon Glass <sjg@chromium.org>",
                "To": "U-Boot Mailing List <u-boot@lists.denx.de>",
                "Cc": "Simon Glass <sjg@chromium.org>, Alexander Kochetkov <al.kochet@gmail.com>,
 Alper Nebi Yasak <alpernebiyasak@gmail.com>,
 Brandon Maier <brandon.maier@collins.com>,
 Jerome Forissier <jerome.forissier@linaro.org>,
 Jiaxun Yang <jiaxun.yang@flygoat.com>,
 Neha Malcom Francis <n-francis@ti.com>,
 Patrick Rudolph <patrick.rudolph@9elements.com>,
 Paul HENRYS <paul.henrys_ext@softathome.com>, Peng Fan <peng.fan@nxp.com>,
 Philippe Reynes <philippe.reynes@softathome.com>,
 Stefan Herbrechtsmeier <stefan.herbrechtsmeier@weidmueller.com>,
 Tom Rini <trini@konsulko.com>",
                "Subject": "[PATCH 0/7] binman: Check code-coverage requirements",
                "Date": "Tue,  4 Mar 2025 06:09:37 -0700",
                "Message-ID": "<20250304130947.109799-1-sjg@chromium.org>",
                "X-Mailer": "git-send-email 2.43.0",
                "MIME-Version": "1.0",
                "Content-Transfer-Encoding": "8bit",
                "X-BeenThere": "u-boot@lists.denx.de",
                "X-Mailman-Version": "2.1.39",
                "Precedence": "list",
                "List-Id": "U-Boot discussion <u-boot.lists.denx.de>",
                "List-Unsubscribe": "<https://lists.denx.de/options/u-boot>,
 <mailto:u-boot-request@lists.denx.de?subject=unsubscribe>",
                "List-Archive": "<https://lists.denx.de/pipermail/u-boot/>",
                "List-Post": "<mailto:u-boot@lists.denx.de>",
                "List-Help": "<mailto:u-boot-request@lists.denx.de?subject=help>",
                "List-Subscribe": "<https://lists.denx.de/listinfo/u-boot>,
 <mailto:u-boot-request@lists.denx.de?subject=subscribe>",
                "Errors-To": "u-boot-bounces@lists.denx.de",
                "Sender": "\"U-Boot\" <u-boot-bounces@lists.denx.de>",
                "X-Virus-Scanned": "clamav-milter 0.103.8 at phobos.denx.de",
                "X-Virus-Status": "Clean"
            content (str): Email content, e.g. 'This series adds a cover-coverage check to CI for Binman. The iMX8 tests
are still not completed,...'
        """
        async with aiohttp.ClientSession() as client:
            return await self._request(client, f'covers/{cover_id}/')

    async def get_cover_comments(self, client, cover_id):
        """Read comments about a cover letter

        Args:
            client (aiohttp.ClientSession): Session to use
            cover_id (str): Patchwork cover-letter ID

        Returns: list of dict: list of comments, each:
            id (int): series ID unique across patchwork instance, e.g. 3472068
            web_url (str): Full URL, e.g. 'https://patchwork.ozlabs.org/comment/3472068/'
            list_archive_url: (unknown?)

            project (dict): project information (id, url, name, link_name,
                list_id, list_email, etc.
            url (str): Full URL, e.g. 'https://patchwork.ozlabs.org/api/1.2/covers/2054866/'
            web_url (str): Full URL, e.g. 'https://patchwork.ozlabs.org/project/uboot/cover/20250304130947.109799-1-sjg@chromium.org/'
            project (dict): project information (id, url, name, link_name,
                list_id, list_email, etc.
            date (str): Date, e.g. '2025-03-04T13:16:15'
            subject (str): 'Re: [PATCH 0/7] binman: Check code-coverage requirements'
            submitter (dict): id, url, name, email, e.g.:
                "id": 6170,
                "url": "https://patchwork.ozlabs.org/api/people/6170/",
                "name": "Simon Glass",
                "email": "sjg@chromium.org"
            content (str): Email content, e.g. 'Hi,

On Tue, 4 Mar 2025 at 06:09, Simon Glass <sjg@chromium.org> wrote:
>
> This '...
            headers: dict: email headers, see get_cover() for an example
        """
        return await self._request(client, f'covers/{cover_id}/comments/')

    async def get_series_url(self, link):
        """Get the URL for a series

        Args:
            link (str): Patchwork series ID

        Returns:
            str: URL for the series page
        """
        return f'{self.url}/project/{self.link_name}/list/?series={link}&state=*&archive=both'

    async def _get_patch_status(self, client, patch_id):
        """Get the patch status

        Args:
            client (aiohttp.ClientSession): Session to use
            patch_id (int): Patch ID to look up in patchwork

        Return:
            PATCH: Patch information

        Requests:
            1 for patch, 1 for patch comments
        """
        data = await self.get_patch(client, patch_id)
        state = data['state']
        comment_data = await self._get_patch_comments(client, patch_id)

        return Patch(patch_id, state, data, comment_data)

    async def get_series_cover(self, client, data):
        """Get the cover information (including comments)

        Args:
            client (aiohttp.ClientSession): Session to use
            data (dict): Return value from self.get_series()

        Returns:
            COVER object, or None if no cover letter
        """
        # Patchwork should always provide this, but use get() so that we don't
        # have to provide it in our fake patchwork _fake_patchwork_cser()
        cover = data.get('cover_letter')
        cover_id = None
        if cover:
            cover_id = cover['id']
            info = await self.get_cover_comments(client, cover_id)
            cover = COVER(cover_id, len(info), cover['name'], info)
        return cover

    async def series_get_state(self, client, link, read_comments,
                               read_cover_comments):
        """Sync the series information against patchwork, to find patch status

        Args:
            client (aiohttp.ClientSession): Session to use
            link (str): Patchwork series ID
            read_comments (bool): True to read the comments on the patches
            read_cover_comments (bool): True to read the comments on the cover
                letter

        Return: tuple:
            COVER object, or None if none or not read_cover_comments
            list of PATCH objects
        """
        data = await self.get_series(client, link)
        patch_list = list(data['patches'])

        count = len(patch_list)
        patches = []
        if read_comments:
            # Returns a list of Patch objects
            tasks = [self._get_patch_status(client, patch_list[i]['id'])
                     for i in range(count)]

            patch_status = await asyncio.gather(*tasks)
            for patch_data, status in zip(patch_list, patch_status):
                status.series_data = patch_data
                patches.append(status)
        else:
            for i in range(count):
                info = patch_list[i]
                pat = Patch(info['id'], series_data=info)
                pat.raw_subject = info['name']
                patches.append(pat)
        if self._show_progress:
            terminal.print_clear()

        if read_cover_comments:
            cover = await self.get_series_cover(client, data)
        else:
            cover = None

        return cover, patches