patch-manager 0.0.10__tar.gz → 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {patch_manager-0.0.10 → patch_manager-0.1.0}/PKG-INFO +5 -1
  2. {patch_manager-0.0.10 → patch_manager-0.1.0}/README.rst +4 -0
  3. {patch_manager-0.0.10 → patch_manager-0.1.0}/patch_manager.egg-info/PKG-INFO +5 -1
  4. {patch_manager-0.0.10 → patch_manager-0.1.0}/patch_manager.egg-info/SOURCES.txt +1 -0
  5. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/__main__.py +2 -1
  6. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/cmdline.py +39 -0
  7. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/control.py +5 -1
  8. patch_manager-0.1.0/patman/coverity.py +127 -0
  9. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/cseries.py +52 -3
  10. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/database.py +35 -9
  11. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/gmail.py +18 -0
  12. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/patchwork.py +13 -0
  13. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/patman.rst +154 -8
  14. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/review.py +780 -66
  15. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/settings.py +25 -9
  16. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/test_cseries.py +688 -0
  17. patch_manager-0.1.0/patman/test_settings.py +149 -0
  18. {patch_manager-0.0.10 → patch_manager-0.1.0}/pyproject.toml +1 -1
  19. patch_manager-0.0.10/patman/test_settings.py +0 -67
  20. {patch_manager-0.0.10 → patch_manager-0.1.0}/LICENSE +0 -0
  21. {patch_manager-0.0.10 → patch_manager-0.1.0}/patch_manager.egg-info/dependency_links.txt +0 -0
  22. {patch_manager-0.0.10 → patch_manager-0.1.0}/patch_manager.egg-info/entry_points.txt +0 -0
  23. {patch_manager-0.0.10 → patch_manager-0.1.0}/patch_manager.egg-info/requires.txt +0 -0
  24. {patch_manager-0.0.10 → patch_manager-0.1.0}/patch_manager.egg-info/top_level.txt +0 -0
  25. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/__init__.py +0 -0
  26. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/checkpatch.py +0 -0
  27. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/commit.py +0 -0
  28. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/cser_helper.py +0 -0
  29. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/func_test.py +0 -0
  30. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/get_maintainer.py +0 -0
  31. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/patchstream.py +0 -0
  32. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/project.py +0 -0
  33. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/send.py +0 -0
  34. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/series.py +0 -0
  35. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/status.py +0 -0
  36. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/test/0000-cover-letter.patch +0 -0
  37. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/test/0001-pci-Correct-cast-for-sandbox.patch +0 -0
  38. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/test/0002-fdt-Correct-cast-for-sandbox-in-fdtdec_setup_mem_siz.patch +0 -0
  39. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/test/get_maintainer +0 -0
  40. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/test/maintainers +0 -0
  41. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/test/test01.txt +0 -0
  42. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/test_checkpatch.py +0 -0
  43. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/test_common.py +0 -0
  44. {patch_manager-0.0.10 → patch_manager-0.1.0}/patman/workflow.py +0 -0
  45. {patch_manager-0.0.10 → patch_manager-0.1.0}/setup.cfg +0 -0
  46. {patch_manager-0.0.10 → patch_manager-0.1.0}/u_boot_pylib/__init__.py +0 -0
  47. {patch_manager-0.0.10 → patch_manager-0.1.0}/u_boot_pylib/__main__.py +0 -0
  48. {patch_manager-0.0.10 → patch_manager-0.1.0}/u_boot_pylib/claude.py +0 -0
  49. {patch_manager-0.0.10 → patch_manager-0.1.0}/u_boot_pylib/command.py +0 -0
  50. {patch_manager-0.0.10 → patch_manager-0.1.0}/u_boot_pylib/cros_subprocess.py +0 -0
  51. {patch_manager-0.0.10 → patch_manager-0.1.0}/u_boot_pylib/gitutil.py +0 -0
  52. {patch_manager-0.0.10 → patch_manager-0.1.0}/u_boot_pylib/terminal.py +0 -0
  53. {patch_manager-0.0.10 → patch_manager-0.1.0}/u_boot_pylib/test_claude.py +0 -0
  54. {patch_manager-0.0.10 → patch_manager-0.1.0}/u_boot_pylib/test_command.py +0 -0
  55. {patch_manager-0.0.10 → patch_manager-0.1.0}/u_boot_pylib/test_gitutil.py +0 -0
  56. {patch_manager-0.0.10 → patch_manager-0.1.0}/u_boot_pylib/test_util.py +0 -0
  57. {patch_manager-0.0.10 → patch_manager-0.1.0}/u_boot_pylib/tools.py +0 -0
  58. {patch_manager-0.0.10 → patch_manager-0.1.0}/u_boot_pylib/tout.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patch-manager
3
- Version: 0.0.10
3
+ Version: 0.1.0
4
4
  Summary: Create, check and email patch series, with Patchwork integration
5
5
  Author-email: Simon Glass <sjg@chromium.org>
6
6
  License-Expression: GPL-2.0-or-later
@@ -35,6 +35,10 @@ Requires-Dist: claude-agent-sdk; extra == "all"
35
35
  Requires-Dist: concurrencytest; extra == "all"
36
36
  Dynamic: license-file
37
37
 
38
+ .. image:: https://raw.githubusercontent.com/nxtboot/patman/master/logo/patman-logo-primary.png
39
+ :alt: Patman
40
+ :width: 400px
41
+
38
42
  Patman patch manager
39
43
  ====================
40
44
 
@@ -1,3 +1,7 @@
1
+ .. image:: https://raw.githubusercontent.com/nxtboot/patman/master/logo/patman-logo-primary.png
2
+ :alt: Patman
3
+ :width: 400px
4
+
1
5
  Patman patch manager
2
6
  ====================
3
7
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patch-manager
3
- Version: 0.0.10
3
+ Version: 0.1.0
4
4
  Summary: Create, check and email patch series, with Patchwork integration
5
5
  Author-email: Simon Glass <sjg@chromium.org>
6
6
  License-Expression: GPL-2.0-or-later
@@ -35,6 +35,10 @@ Requires-Dist: claude-agent-sdk; extra == "all"
35
35
  Requires-Dist: concurrencytest; extra == "all"
36
36
  Dynamic: license-file
37
37
 
38
+ .. image:: https://raw.githubusercontent.com/nxtboot/patman/master/logo/patman-logo-primary.png
39
+ :alt: Patman
40
+ :width: 400px
41
+
38
42
  Patman patch manager
39
43
  ====================
40
44
 
@@ -13,6 +13,7 @@ patman/checkpatch.py
13
13
  patman/cmdline.py
14
14
  patman/commit.py
15
15
  patman/control.py
16
+ patman/coverity.py
16
17
  patman/cser_helper.py
17
18
  patman/cseries.py
18
19
  patman/database.py
@@ -40,6 +40,7 @@ def run_patman():
40
40
  from patman import func_test
41
41
  from patman import test_checkpatch
42
42
  from patman import test_cseries
43
+ from patman import test_settings
43
44
  from u_boot_pylib import test_command
44
45
 
45
46
  to_run = args.testname if args.testname not in [None, 'test'] else None
@@ -48,7 +49,7 @@ def run_patman():
48
49
  args.test_preserve_dirs, None, to_run, None,
49
50
  [test_checkpatch.TestPatch, func_test.TestFunctional,
50
51
  'patman.settings', test_cseries.TestCseries,
51
- test_command.TestRunInteractive])
52
+ test_settings.TestSettings, test_command.TestRunInteractive])
52
53
  sys.exit(0 if result.wasSuccessful() else 1)
53
54
 
54
55
  # Process commits, produce patches files, check them, email them
@@ -342,6 +342,16 @@ def add_series_subparser(subparsers):
342
342
  ren = series_subparsers.add_parser('rename')
343
343
  ren.add_argument('-N', '--new-name', help='New name for the series')
344
344
 
345
+ rev = series_subparsers.add_parser(
346
+ 'review', help='AI-review the series and store the result')
347
+ rev.add_argument('-f', '--force', action='store_true',
348
+ help='Re-review even if reviews are already stored')
349
+ rev.add_argument('--spelling', type=str, default='British',
350
+ help='Spelling convention for review comments')
351
+ rev.add_argument('-c', '--context', type=str, default=None,
352
+ help="Extra context for the review agent, or '@path' "
353
+ 'to read it from a file')
354
+
345
355
  series_subparsers.add_parser('rm')
346
356
 
347
357
  snotes = series_subparsers.add_parser('save-notes')
@@ -587,6 +597,10 @@ def add_review_subparser(subparsers):
587
597
  review.add_argument(
588
598
  '-d', '--create-drafts', action='store_true',
589
599
  help='Create Gmail draft emails for each review')
600
+ review.add_argument(
601
+ '--redraft', action='store_true',
602
+ help='Recreate Gmail drafts from the stored reviews even when a '
603
+ 'draft already exists, to recover after an error')
590
604
  review.add_argument(
591
605
  '--gmail-account', type=str, default=None,
592
606
  help='Gmail account to create drafts in (e.g. user@gmail.com)')
@@ -607,6 +621,14 @@ def add_review_subparser(subparsers):
607
621
  review.add_argument(
608
622
  '--apply-only', action='store_true',
609
623
  help='Only download and apply patches, skip AI review')
624
+ review.add_argument(
625
+ '--coverity', action='store_true',
626
+ help='Run Coverity on the base and the series, and feed the new '
627
+ 'defects into the review (needs the cov-* tools on PATH)')
628
+ review.add_argument(
629
+ '--coverity-defconfig', type=str, default=None,
630
+ help='Board defconfig to build for --coverity '
631
+ '(default: sandbox_defconfig)')
610
632
  review.add_argument(
611
633
  '--signoff', type=str, default='',
612
634
  help="Sign-off for reviews with comments (from .patman settings)")
@@ -627,9 +649,26 @@ def add_review_subparser(subparsers):
627
649
  '--sync', action='store_true',
628
650
  help='Check if review drafts have been sent and record the '
629
651
  'final email content')
652
+ review.add_argument(
653
+ '--scan', action='store_true',
654
+ help='Scan patchwork for new versions of already-reviewed series '
655
+ 'and review the latest version once it has fully appeared')
656
+ review.add_argument(
657
+ '--relink', action='store_true',
658
+ help='Repair the database by merging review series that were split '
659
+ 'across versions, so follow-up reviews see earlier feedback')
660
+ review.add_argument(
661
+ '-j', '--jobs', type=int, default=4,
662
+ help='Number of series to review in parallel with --scan '
663
+ '(default: 4)')
630
664
  review.add_argument(
631
665
  '-f', '--force', action='store_true',
632
666
  help='Force re-review even if the series was already reviewed')
667
+ review.add_argument(
668
+ '--any-state', action='store_true',
669
+ help='Review the series even if no patch is in an active state '
670
+ '(new, RFC, under-review, changes-requested or '
671
+ 'needs-review-ack)')
633
672
  review.add_argument(
634
673
  '-c', '--context', type=str, default=None,
635
674
  help="Extra context for the review agent — e.g. 'this is RFC, "
@@ -245,6 +245,9 @@ def do_series(args, test_db=None, pwork=None, cser=None):
245
245
  cser.show_notes(args.series)
246
246
  elif args.subcmd == 'rename':
247
247
  cser.rename(args.series, args.new_name, dry_run=args.dry_run)
248
+ elif args.subcmd == 'review':
249
+ cser.review(args.series, args.version, force=args.force,
250
+ spelling=args.spelling, context=args.context)
248
251
  elif args.subcmd == 'set-upstream':
249
252
  cser.set_upstream(args.series, args.upstream_name,
250
253
  dry_run=args.dry_run)
@@ -433,7 +436,8 @@ def do_review(args, test_db=None, pwork=None, cser=None):
433
436
  cser.open_database()
434
437
 
435
438
  # Resolve patchwork URL
436
- if not pwork and not args.learn_voice and not args.sync:
439
+ if not pwork and not args.learn_voice and not args.sync \
440
+ and not args.relink:
437
441
  ups = args.upstream
438
442
  if not ups:
439
443
  ups = cser.db.upstream_get_default()
@@ -0,0 +1,127 @@
1
+ # SPDX-License-Identifier: GPL-2.0+
2
+ #
3
+ # Copyright 2026 Simon Glass <sjg@chromium.org>
4
+ #
5
+ """Run Coverity static analysis on a series and find new defects
6
+
7
+ Coverity works on a whole build rather than a diff, so to find the
8
+ defects a series introduces we analyse the base tree and the patched
9
+ tree separately and compare. Each Coverity defect carries a stable
10
+ 'mergeKey' that identifies the same defect across builds, so the new
11
+ defects are those whose mergeKey is not present in the base.
12
+
13
+ The cov-build, cov-analyze and cov-format-errors tools must be on the
14
+ PATH; they are part of a (commercial) Coverity installation.
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+
22
+ from u_boot_pylib import tout
23
+
24
+ # Default board config to build for analysis (host build, no toolchain)
25
+ DEFAULT_DEFCONFIG = 'sandbox_defconfig'
26
+
27
+
28
+ def check_available():
29
+ """Check whether the Coverity command-line tools are installed
30
+
31
+ Returns:
32
+ bool: True if cov-build, cov-analyze and cov-format-errors are all
33
+ on the PATH
34
+ """
35
+ return all(shutil.which(tool) for tool in
36
+ ('cov-build', 'cov-analyze', 'cov-format-errors'))
37
+
38
+
39
+ def _run(cmd, cwd):
40
+ """Run a command, raising on failure
41
+
42
+ Args:
43
+ cmd (list of str): Command and arguments
44
+ cwd (str): Directory to run in
45
+
46
+ Raises:
47
+ subprocess.CalledProcessError: if the command fails
48
+ """
49
+ subprocess.run(cmd, cwd=cwd, check=True, stdout=subprocess.PIPE,
50
+ stderr=subprocess.STDOUT, text=True)
51
+
52
+
53
+ def analyze(repo_path, defconfig, emit_dir):
54
+ """Build and analyse a tree, returning its Coverity defects
55
+
56
+ Configures the given defconfig, builds it under cov-build, runs
57
+ cov-analyze and reads the defects back as JSON.
58
+
59
+ Args:
60
+ repo_path (str): Worktree to configure and build in
61
+ defconfig (str): Board defconfig to build, e.g. 'sandbox_defconfig'
62
+ emit_dir (str): Coverity intermediate directory to write
63
+
64
+ Returns:
65
+ list of dict: Defect records, each with at least 'mergeKey'
66
+
67
+ Raises:
68
+ subprocess.CalledProcessError: if a build or analysis step fails
69
+ """
70
+ jobs = str(os.cpu_count() or 1)
71
+ _run(['make', defconfig], repo_path)
72
+ _run(['cov-build', '--dir', emit_dir, 'make', '-j', jobs], repo_path)
73
+ _run(['cov-analyze', '--dir', emit_dir], repo_path)
74
+ out_json = os.path.join(emit_dir, 'defects.json')
75
+ _run(['cov-format-errors', '--dir', emit_dir, '--json-output-v7',
76
+ out_json], repo_path)
77
+ with open(out_json, encoding='utf-8') as fd:
78
+ data = json.load(fd)
79
+ return data.get('issues', [])
80
+
81
+
82
+ def find_new_defects(base, patched):
83
+ """Find defects present in the patched tree but not the base
84
+
85
+ Args:
86
+ base (list of dict): Defects from the base tree
87
+ patched (list of dict): Defects from the patched tree
88
+
89
+ Returns:
90
+ list of dict: Defects whose mergeKey is not in the base
91
+ """
92
+ base_keys = {defect.get('mergeKey') for defect in base}
93
+ return [defect for defect in patched
94
+ if defect.get('mergeKey') not in base_keys]
95
+
96
+
97
+ def format_defect(defect):
98
+ """Format a single defect as a one-line summary
99
+
100
+ Args:
101
+ defect (dict): Coverity defect record
102
+
103
+ Returns:
104
+ str: Summary like 'RESOURCE_LEAK: drivers/foo.c:42 (probe): ...'
105
+ """
106
+ checker = defect.get('checkerName', '?')
107
+ path = defect.get('mainEventFilePathname', '?')
108
+ line = defect.get('mainEventLineNumber', '?')
109
+ func = defect.get('functionDisplayName', '')
110
+ desc = (defect.get('subcategoryLongDescription') or
111
+ defect.get('subcategoryShortDescription') or '')
112
+ loc = f'{path}:{line}'
113
+ if func:
114
+ loc += f' ({func})'
115
+ return f'{checker}: {loc}: {desc}'.rstrip(': ')
116
+
117
+
118
+ def format_defects(defects):
119
+ """Format a list of defects as a bullet list, most useful first
120
+
121
+ Args:
122
+ defects (list of dict): Defects to format
123
+
124
+ Returns:
125
+ str: One '- <summary>' line per defect, or '' if there are none
126
+ """
127
+ return '\n'.join(f'- {format_defect(defect)}' for defect in defects)
@@ -341,9 +341,15 @@ class Cseries(cser_helper.CseriesHelper):
341
341
  _, ser, version, _, _, _, _, _ = self._get_patches(series, version)
342
342
  svinfo = self.get_ser_ver(ser.idnum, version)
343
343
 
344
- # Use the per-version description if available, since the
345
- # series-level desc may be stale (e.g. patch order changed)
346
- desc = svinfo.desc or ser.desc
344
+ # Prefer the live cover-letter title from the branch: it is what
345
+ # patchwork indexes the series by, and it reflects a rename made
346
+ # after the version was created (the stored description would be
347
+ # stale in that case). Fall back to the stored per-version
348
+ # description for series with no cover letter, where it is more
349
+ # stable than the first commit subject (e.g. if patches are
350
+ # reordered).
351
+ desc = ser.cover[0] if ser.cover else None
352
+ desc = desc or svinfo.desc or ser.desc
347
353
  if not desc:
348
354
  raise ValueError(f"Series '{ser.name}' has an empty description")
349
355
  ser.desc = desc
@@ -1501,6 +1507,49 @@ class Cseries(cser_helper.CseriesHelper):
1501
1507
  self.rollback()
1502
1508
  tout.info('Dry run completed')
1503
1509
 
1510
+ def review(self, series, version, force=False, spelling='British',
1511
+ context=None):
1512
+ """AI-review the series and store the result in the database
1513
+
1514
+ Reviews the commits on the series branch with the review agent and
1515
+ stores the findings, keyed by version, so they can be viewed later
1516
+ with 'patman series info -r'. The series must already have been
1517
+ added with 'patman series add'.
1518
+
1519
+ Args:
1520
+ series (str): Name of series, or None to use the current branch
1521
+ version (int): Version number, or None to detect from the branch
1522
+ force (bool): Re-review even if reviews are already stored
1523
+ spelling (str): Spelling convention for review comments
1524
+ context (str or None): Extra context for the review agent, or
1525
+ '@path' to read it from a file
1526
+ """
1527
+ from patman import review
1528
+
1529
+ ser, version = self._parse_series_and_version(series, version)
1530
+ self._ensure_in_db(ser)
1531
+ self._ensure_version(ser, version)
1532
+ svid = self.get_ser_ver(ser.idnum, version).idnum
1533
+
1534
+ existing = self.db.review_get_for_version(svid)
1535
+ if existing and not force:
1536
+ raise ValueError(
1537
+ f"Series '{ser.name}' v{version} already has "
1538
+ f"{len(existing)} stored review(s); view with 'patman "
1539
+ "series info -r' or use -f to re-review")
1540
+ if existing:
1541
+ self.db.review_delete_for_version(svid)
1542
+
1543
+ name = self._get_branch_name(ser.name, version)
1544
+ if not gitutil.check_branch(name, git_dir=self.gitdir):
1545
+ raise ValueError(f"No branch named '{name}'")
1546
+ count = gitutil.count_commits_to_branch(name, self.gitdir)
1547
+ series_obj = patchstream.get_metadata(name, 0, count,
1548
+ git_dir=self.gitdir)
1549
+
1550
+ review.review_series(self, ser.idnum, svid, version, name,
1551
+ series_obj, spelling=spelling, context=context)
1552
+
1504
1553
  def gather_all(self, pwork, show_comments, show_cover_comments,
1505
1554
  sync_all_versions, gather_tags, dry_run=False):
1506
1555
  to_fetch, missing = self._get_fetch_dict(sync_all_versions)
@@ -830,6 +830,29 @@ class Database: # pylint:disable=R0904
830
830
  return SerVer(*recs[0])
831
831
  return [SerVer(*x) for x in recs]
832
832
 
833
+ def ser_ver_get_svids(self, series_idnum):
834
+ """Get all ser_ver IDs for a series
835
+
836
+ Args:
837
+ series_idnum (int): Series ID
838
+
839
+ Return:
840
+ list of int: svids of every version of the series
841
+ """
842
+ res = self.execute('SELECT id FROM ser_ver WHERE series_id = ?',
843
+ (series_idnum,))
844
+ return [row[0] for row in res.fetchall()]
845
+
846
+ def ser_ver_set_series(self, svid, series_idnum):
847
+ """Move a ser_ver record to a different series
848
+
849
+ Args:
850
+ svid (int): ser_ver ID to move
851
+ series_idnum (int): Series ID to attach it to
852
+ """
853
+ self.execute('UPDATE ser_ver SET series_id = ? WHERE id = ?',
854
+ (series_idnum, svid))
855
+
833
856
  def ser_ver_get_ids_for_series(self, series_idnum, version=None):
834
857
  """Get a list of ser_ver records for a given series ID
835
858
 
@@ -1431,25 +1454,28 @@ class Database: # pylint:disable=R0904
1431
1454
  return [Review(*row) for row in res.fetchall()]
1432
1455
 
1433
1456
  def review_get_previous(self, series_id, version):
1434
- """Get reviews from the previous version of a series
1457
+ """Get reviews from the most recent earlier version of a series
1435
1458
 
1436
- Looks up the ser_ver for version-1 and returns its reviews, so
1437
- they can be provided as context when reviewing a new version.
1459
+ Returns the reviews of the highest version below the current one
1460
+ that actually has reviews, so they can be provided as context when
1461
+ reviewing a new version. Skipping over versions with no reviews
1462
+ means a gap (e.g. an intermediate version that was never reviewed)
1463
+ still yields the earlier feedback rather than nothing.
1438
1464
 
1439
1465
  Args:
1440
1466
  series_id (int): Series ID
1441
1467
  version (int): Current version being reviewed
1442
1468
 
1443
1469
  Return:
1444
- list of Review: Reviews from version-1, or empty list
1470
+ list of Review: Reviews from the latest earlier reviewed
1471
+ version, or empty list
1445
1472
  """
1446
- prev_version = version - 1
1447
- if prev_version < 1:
1448
- return []
1449
1473
  res = self.execute(
1450
1474
  'SELECT sv.id FROM ser_ver sv '
1451
- 'WHERE sv.series_id = ? AND sv.version = ?',
1452
- (series_id, prev_version))
1475
+ 'JOIN review r ON r.svid = sv.id '
1476
+ 'WHERE sv.series_id = ? AND sv.version < ? '
1477
+ 'ORDER BY sv.version DESC LIMIT 1',
1478
+ (series_id, version))
1453
1479
  row = res.fetchone()
1454
1480
  if not row:
1455
1481
  return []
@@ -324,6 +324,24 @@ def create_draft(service, to, subject, body,
324
324
  return draft
325
325
 
326
326
 
327
+ def delete_draft(service, draft_id):
328
+ """Delete a Gmail draft
329
+
330
+ A draft that no longer exists (already sent or deleted by hand) is
331
+ ignored rather than raising.
332
+
333
+ Args:
334
+ service (googleapiclient.discovery.Resource): Gmail API service
335
+ draft_id (str): ID of the draft to delete
336
+ """
337
+ from googleapiclient.errors import HttpError
338
+ try:
339
+ service.users().drafts().delete(userId='me', id=draft_id).execute()
340
+ except HttpError as exc:
341
+ if exc.resp.status != 404:
342
+ raise
343
+
344
+
327
345
  def _build_cc(headers, list_email):
328
346
  """Build a CC list from the original patch headers
329
347
 
@@ -452,6 +452,19 @@ class Patchwork:
452
452
  """
453
453
  return await self._request(client, f'series/{link}/')
454
454
 
455
+ async def get_series_patch_states(self, client, link):
456
+ """Get the patchwork state of each patch in a series
457
+
458
+ Args:
459
+ client (aiohttp.ClientSession): Session to use
460
+ link (str): Patchwork series ID
461
+
462
+ Returns:
463
+ list of str: State slug for each patch, e.g. ['new', 'rfc']
464
+ """
465
+ res = await self._request(client, f'patches/?series={link}')
466
+ return [patch.get('state') for patch in res]
467
+
455
468
  async def get_series_mbox(self, client, link):
456
469
  """Download the raw mbox file for a series.
457
470