mbox series
Message ID20260604141826.2998337-4-robin@jarry.cc
StateNew
Delegate
ArchivedNo
Headers
show
Return-Path: <robin@jarry.cc>
Received: from ringo (2a01cb00021ec0002e23edbec21b0e73.ipv6.abo.wanadoo.fr
 [IPv6:2a01:cb00:21e:c000:2e23:edbe:c21b:e73])
	by patches.jarry.cc (Postfix) with ESMTP id 818431BC434E
	for <pw@patches.jarry.cc>; Thu, 04 Jun 2026 16:18:29 +0200 (CEST)
From: Robin Jarry <robin@jarry.cc>
To: pw@patches.jarry.cc
Subject: [PATCH v3 03/16] series: add respin tracking for patch series
 versions
Date: Thu,  4 Jun 2026 16:18:12 +0200
Message-ID: <20260604141826.2998337-4-robin@jarry.cc>
X-Mailer: git-send-email 2.54.0
In-Reply-To: <20260604141826.2998337-1-robin@jarry.cc>
References: <20260604141826.2998337-1-robin@jarry.cc>
MIME-Version: 1.0
List-ID: <pw.jarry.cc>
Content-Transfer-Encoding: 8bit
Series
Implement forge bidirectional sync
github_prhttps://github.com/rjarry/patchwork/pull/4
github_branchpatchwork/implement-forge-bidirectional-sync-c

Commit Message

Robin JarryJun. 4, 2026, 16:18. UTC
[v3,03/16] series: add respin tracking for patch series versions

When a new version of a patch series is received (e.g. [PATCH v3]),
automatically link it to the previous version via a new previous_series
foreign key on the Series model. This creates a version chain that can
be walked in both directions to find all versions of a series.

The matching heuristic works in two tiers. First, In-Reply-To and
References headers are checked against SeriesReference entries to find
a threading link to any earlier version of the series from the same
submitter. Since respins may reply to any previous version (e.g. v3
replying to v1 instead of v2), the code walks the matched series'
version chain to locate the direct predecessor (version N-1). If no
match is found through references, fall back to comparing series names
and individual patch subjects using SequenceMatcher with a 0.8
similarity threshold, filtered by same submitter and consecutive
version number.

A new per-project auto_supersede boolean (default off) allows
automatically marking all patches of the previous series version as
"Superseded" when a newer version is linked.

The REST API exposes previous_series and next_series fields on the
series endpoint (API v1.4). The web UI shows a "Versions" row on the
patch and cover letter detail pages with links to all versions.

Signed-off-by: Robin Jarry <rjarry@redhat.com>
---
 patchwork/api/series.py                       |  20 ++-
 .../migrations/0049_series_respin_tracking.py |  33 +++++
 patchwork/models.py                           |  34 +++++
 patchwork/parser.py                           | 139 ++++++++++++++++++
 patchwork/templates/patchwork/submission.html |  15 ++
 patchwork/tests/api/test_series.py            |   2 +-
 patchwork/views/cover.py                      |   3 +
 patchwork/views/patch.py                      |   2 +
 ...ries-respin-tracking-c3d4e5f6g7h8i9j0.yaml |  16 ++
 9 files changed, 261 insertions(+), 3 deletions(-)
 create mode 100644 patchwork/migrations/0049_series_respin_tracking.py
 create mode 100644 releasenotes/notes/series-respin-tracking-c3d4e5f6g7h8i9j0.yaml

Patch

mbox series
diff --git a/patchwork/api/series.py b/patchwork/api/series.py
index c9f5f54bdbfd..2ad805eadd13 100644
--- a/patchwork/api/series.py
+++ b/patchwork/api/series.py
@@ -33,6 +33,12 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer):
     dependents = HyperlinkedRelatedField(
         read_only=True, view_name='api-series-detail', many=True
     )
+    previous_series = HyperlinkedRelatedField(
+        read_only=True, view_name='api-series-detail'
+    )
+    next_series = HyperlinkedRelatedField(
+        read_only=True, view_name='api-series-detail', many=True
+    )
 
     def get_web_url(self, instance):
         request = self.context.get('request')
@@ -71,6 +77,8 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer):
             'patches',
             'dependencies',
             'dependents',
+            'previous_series',
+            'next_series',
         )
         read_only_fields = (
             'date',
@@ -83,10 +91,17 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer):
             'patches',
             'dependencies',
             'dependents',
+            'previous_series',
+            'next_series',
         )
         versioned_fields = {
             '1.1': ('web_url',),
-            '1.4': ('dependencies', 'dependents'),
+            '1.4': (
+                'dependencies',
+                'dependents',
+                'previous_series',
+                'next_series',
+            ),
         }
         extra_kwargs = {
             'url': {'view_name': 'api-series-detail'},
@@ -105,8 +120,9 @@ class SeriesMixin(object):
                 'cover_letter__project',
                 'dependencies',
                 'dependents',
+                'next_series',
             )
-            .select_related('submitter', 'project')
+            .select_related('submitter', 'project', 'previous_series')
         )
 
 
diff --git a/patchwork/migrations/0049_series_respin_tracking.py b/patchwork/migrations/0049_series_respin_tracking.py
new file mode 100644
index 000000000000..18e5ddba915c
--- /dev/null
+++ b/patchwork/migrations/0049_series_respin_tracking.py
@@ -0,0 +1,33 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('patchwork', '0048_series_dependencies'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='series',
+            name='previous_series',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='next_series',
+                to='patchwork.series',
+            ),
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='auto_supersede',
+            field=models.BooleanField(
+                default=False,
+                help_text=(
+                    'Automatically mark patches of previous series versions '
+                    'as superseded when a new version is received.'
+                ),
+            ),
+        ),
+    ]
diff --git a/patchwork/models.py b/patchwork/models.py
index ae2f4a6dcbaa..387490a7094f 100644
--- a/patchwork/models.py
+++ b/patchwork/models.py
@@ -104,6 +104,11 @@ class Project(models.Model):
         default=False,
         help_text='Enable dependency tracking for patches and cover letters.',
     )
+    auto_supersede = models.BooleanField(
+        default=False,
+        help_text='Automatically mark patches of previous series versions '
+        'as superseded when a new version is received.',
+    )
     use_tags = models.BooleanField(default=True)
 
     def is_editable(self, user):
@@ -858,6 +863,15 @@ class Series(FilenameMixin, models.Model):
         related_query_name='dependent',
     )
 
+    # respin tracking
+    previous_series = models.ForeignKey(
+        'self',
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+        related_name='next_series',
+    )
+
     # metadata
     name = models.CharField(
         max_length=255,
@@ -967,6 +981,26 @@ class Series(FilenameMixin, models.Model):
 
         return patch
 
+    def get_version_chain(self):
+        """Return all versions of this series, oldest first."""
+        chain = [self]
+        current = self
+        while current.previous_series_id:
+            current = current.previous_series
+            chain.append(current)
+        chain.reverse()
+        current = self
+        for newer in current.next_series.order_by('version'):
+            chain.append(newer)
+            # follow further respins from each newer version
+            queue = list(newer.next_series.order_by('version'))
+            while queue:
+                s = queue.pop(0)
+                if s not in chain:
+                    chain.append(s)
+                    queue.extend(s.next_series.order_by('version'))
+        return chain
+
     def get_absolute_url(self):
         # TODO(stephenfin): We really need a proper series view
         return reverse(
diff --git a/patchwork/parser.py b/patchwork/parser.py
index fafa47ba9e78..13d043069944 100644
--- a/patchwork/parser.py
+++ b/patchwork/parser.py
@@ -6,6 +6,7 @@
 import codecs
 import datetime
 from datetime import timezone
+from difflib import SequenceMatcher
 from email.header import decode_header
 from email.header import make_header
 from email.utils import mktime_tz
@@ -335,6 +336,126 @@ def find_series(project, mail, author):
     return _find_series_by_markers(project, mail, author)
 
 
+def _strip_name_prefixes(name):
+    """Strip leading bracketed prefixes from a series or patch name."""
+    if not name:
+        return ''
+    prefix_re = re.compile(r'^\[([^\]]*)\]\s*')
+    return prefix_re.sub('', name).strip()
+
+
+def _name_similarity(a, b):
+    """Return similarity ratio between two stripped names."""
+    a = _strip_name_prefixes(a)
+    b = _strip_name_prefixes(b)
+    if not a or not b:
+        return 0.0
+    return SequenceMatcher(None, a.lower(), b.lower()).ratio()
+
+
+def _find_version_in_chain(series, target_version):
+    """Walk a series' version chain to find a specific version."""
+    # walk backward
+    current = series
+    while current:
+        if current.version == target_version:
+            return current
+        current = current.previous_series
+
+    # walk forward
+    current = series
+    while current:
+        if current.version == target_version:
+            return current
+        nxt = current.next_series.order_by('version').first()
+        if nxt and nxt.id != current.id:
+            current = nxt
+        else:
+            break
+
+    return None
+
+
+def find_previous_series(project, series, refs):
+    """Find the previous version of a series for respin tracking.
+
+    Uses a two-tier heuristic: first check mail references for a
+    direct threading link to a related series, then fall back to
+    name and patch subject similarity matching.
+    """
+    if series.version <= 1:
+        return None
+
+    prev_version = series.version - 1
+
+    # tier 1: check In-Reply-To / References for a link to any
+    # version of the same series from the same submitter, then walk
+    # the version chain to find version N-1
+    for ref in refs:
+        try:
+            sr = SeriesReference.objects.get(msgid=ref[:255], project=project)
+        except SeriesReference.DoesNotExist:
+            continue
+        linked = sr.series
+        if linked.id == series.id:
+            continue
+        if linked.submitter != series.submitter:
+            continue
+        if linked.version >= series.version:
+            continue
+        prev = _find_version_in_chain(linked, prev_version)
+        if prev:
+            return prev
+
+    # tier 2: name + submitter matching
+    candidates = Series.objects.filter(
+        project=project,
+        submitter=series.submitter,
+        version=prev_version,
+    ).order_by('-date')
+
+    if not candidates.exists():
+        return None
+
+    best = None
+    best_score = 0.0
+
+    for candidate in candidates:
+        score = _name_similarity(series.name, candidate.name)
+
+        # also check individual patch subjects for multi-patch series
+        new_patches = list(series.patches.values_list('name', flat=True))
+        if new_patches:
+            old_patches = list(
+                candidate.patches.values_list('name', flat=True)
+            )
+            if old_patches:
+                matches = 0
+                for np in new_patches:
+                    for op in old_patches:
+                        if _name_similarity(np, op) >= 0.8:
+                            matches += 1
+                            break
+                patch_ratio = matches / len(new_patches)
+                score = max(score, patch_ratio)
+
+        if score >= 0.8 and score > best_score:
+            best = candidate
+            best_score = score
+
+    return best
+
+
+def _mark_previous_as_superseded(series):
+    """Mark all patches in a series as superseded."""
+    try:
+        superseded = State.objects.get(slug='superseded')
+    except State.DoesNotExist:
+        logger.warning('No "superseded" state found, skipping auto-supersede')
+        return
+    series.patches.update(state=superseded)
+
+
 def split_from_header(from_header):
     name, email = (None, None)
 
@@ -1389,6 +1510,15 @@ def parse_mail(mail, list_id=None):
         # parse patch dependencies
         series.add_dependencies(parse_depends_on(message))
 
+        # link to the previous version of this series
+        if not series.previous_series and version > 1:
+            prev = find_previous_series(project, series, refs)
+            if prev:
+                series.previous_series = prev
+                series.save()
+                if project.auto_supersede:
+                    _mark_previous_as_superseded(prev)
+
         return patch
     elif x == 0:  # (potential) cover letters
         # if refs are empty, it's implicitly a cover letter. If not,
@@ -1460,6 +1590,15 @@ def parse_mail(mail, list_id=None):
             # entire patch series; parse them
             series.add_dependencies(parse_depends_on(message))
 
+            # link to the previous version of this series
+            if not series.previous_series and version > 1:
+                prev = find_previous_series(project, series, refs)
+                if prev:
+                    series.previous_series = prev
+                    series.save()
+                    if project.auto_supersede:
+                        _mark_previous_as_superseded(prev)
+
             return cover_letter
 
     # comments
diff --git a/patchwork/templates/patchwork/submission.html b/patchwork/templates/patchwork/submission.html
index cd74491c0e92..b02555eee6d5 100644
--- a/patchwork/templates/patchwork/submission.html
+++ b/patchwork/templates/patchwork/submission.html
@@ -97,6 +97,21 @@
     </td>
   </tr>
 {% endif %}
+{% if version_chain and version_chain|length > 1 %}
+  <tr>
+    <th>Versions</th>
+    <td>
+{% for s in version_chain %}
+{% if s.id == submission.series.id %}
+      <strong>v{{ s.version }}</strong>
+{% else %}
+      <a href="{{ s.get_absolute_url }}">v{{ s.version }}</a>
+{% endif %}
+{% if not forloop.last %} | {% endif %}
+{% endfor %}
+    </td>
+  </tr>
+{% endif %}
 {% if submission.related %}
   <tr>
     <th>Related</th>
diff --git a/patchwork/tests/api/test_series.py b/patchwork/tests/api/test_series.py
index 24d7d9a6899c..e38f6e75553e 100644
--- a/patchwork/tests/api/test_series.py
+++ b/patchwork/tests/api/test_series.py
@@ -197,7 +197,7 @@ class TestSeriesAPI(utils.APITestCase):
             create_cover(series=series_obj)
             create_patch(series=series_obj)
 
-        with self.assertNumQueries(8):
+        with self.assertNumQueries(9):
             self.client.get(self.api_url())
 
     @utils.store_samples('series-detail')
diff --git a/patchwork/views/cover.py b/patchwork/views/cover.py
index 15013a89e1ce..2a501cfed572 100644
--- a/patchwork/views/cover.py
+++ b/patchwork/views/cover.py
@@ -47,6 +47,9 @@ def cover_detail(request, project_id, msgid):
     comments = comments.only('submitter', 'date', 'id', 'content', 'cover')
     context['comments'] = comments
 
+    if cover.series:
+        context['version_chain'] = cover.series.get_version_chain()
+
     return render(request, 'patchwork/submission.html', context)
 
 
diff --git a/patchwork/views/patch.py b/patchwork/views/patch.py
index efe94f17c942..fd36bf98461d 100644
--- a/patchwork/views/patch.py
+++ b/patchwork/views/patch.py
@@ -124,6 +124,8 @@ def patch_detail(request, project_id, msgid):
     context['project'] = patch.project
     context['related_same_project'] = related_same_project
     context['related_different_project'] = related_different_project
+    if patch.series:
+        context['version_chain'] = patch.series.get_version_chain()
     if errors:
         context['errors'] = errors
 
diff --git a/releasenotes/notes/series-respin-tracking-c3d4e5f6g7h8i9j0.yaml b/releasenotes/notes/series-respin-tracking-c3d4e5f6g7h8i9j0.yaml
new file mode 100644
index 000000000000..1d3d99bbf517
--- /dev/null
+++ b/releasenotes/notes/series-respin-tracking-c3d4e5f6g7h8i9j0.yaml
@@ -0,0 +1,16 @@
+---
+features:
+  - |
+    Automatic respin tracking for patch series versions. When a new
+    version of a series is received (e.g. [PATCH v3]), patchwork links
+    it to the previous version using In-Reply-To/References headers or
+    name similarity matching. The patch detail page shows all versions
+    with links to navigate between them.
+  - |
+    New per-project ``auto_supersede`` option. When enabled, receiving a
+    new series version automatically marks all patches of the previous
+    version as superseded.
+api:
+  - |
+    Add ``previous_series`` and ``next_series`` fields to the series
+    endpoint (v1.5) for navigating between series versions.