diff --git a/patchwork/api/series.py b/patchwork/api/series.py
index c9f5f54..2ad805e 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 0000000..18e5ddb
--- /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 ae2f4a6..387490a 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 fafa47b..13d0430 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 cd74491..b02555e 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 24d7d9a..e38f6e7 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 15013a8..2a501cf 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 efe94f1..fd36bf9 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 0000000..1d3d99b
--- /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.