diff --git a/patchwork/admin.py b/patchwork/admin.py
index d1c389a17b99..a26487850936 100644
--- a/patchwork/admin.py
+++ b/patchwork/admin.py
@@ -19,6 +19,7 @@ from patchwork.models import PatchRelation
from patchwork.models import Person
from patchwork.models import Project
from patchwork.models import Series
+from patchwork.models import SeriesMetadata
from patchwork.models import SeriesReference
from patchwork.models import State
from patchwork.models import Tag
@@ -181,6 +182,14 @@ class SeriesReferenceAdmin(admin.ModelAdmin):
admin.site.register(SeriesReference, SeriesReferenceAdmin)
+class SeriesMetadataAdmin(admin.ModelAdmin):
+ model = SeriesMetadata
+ list_display = ('series', 'key', 'value')
+
+
+admin.site.register(SeriesMetadata, SeriesMetadataAdmin)
+
+
class CheckAdmin(admin.ModelAdmin):
list_display = (
'patch',
diff --git a/patchwork/migrations/0050_series_metadata.py b/patchwork/migrations/0050_series_metadata.py
new file mode 100644
index 000000000000..5f6b2923fa9d
--- /dev/null
+++ b/patchwork/migrations/0050_series_metadata.py
@@ -0,0 +1,41 @@
+# Generated by Django 5.1.15 on 2026-05-30 00:43
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('patchwork', '0049_series_respin_tracking'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='SeriesMetadata',
+ fields=[
+ (
+ 'id',
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name='ID',
+ ),
+ ),
+ ('key', models.CharField(db_index=True, max_length=255)),
+ ('value', models.TextField(db_index=True, max_length=255)),
+ (
+ 'series',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='metadata',
+ to='patchwork.series',
+ ),
+ ),
+ ],
+ options={
+ 'ordering': ['key'],
+ 'unique_together': {('series', 'key')},
+ },
+ ),
+ ]
diff --git a/patchwork/models.py b/patchwork/models.py
index 387490a7094f..0b23b3bc495f 100644
--- a/patchwork/models.py
+++ b/patchwork/models.py
@@ -1042,6 +1042,23 @@ class SeriesReference(models.Model):
unique_together = [('project', 'msgid')]
+class SeriesMetadata(models.Model):
+ series = models.ForeignKey(
+ Series,
+ related_name='metadata',
+ on_delete=models.CASCADE,
+ )
+ key = models.CharField(max_length=255, db_index=True)
+ value = models.TextField(max_length=255, db_index=True)
+
+ def __str__(self):
+ return '%s=%s' % (self.key, self.value)
+
+ class Meta:
+ unique_together = [('series', 'key')]
+ ordering = ['key']
+
+
class Bundle(models.Model):
owner = models.ForeignKey(
User,
diff --git a/patchwork/templates/patchwork/submission.html b/patchwork/templates/patchwork/submission.html
index b02555eee6d5..4cec9799b946 100644
--- a/patchwork/templates/patchwork/submission.html
+++ b/patchwork/templates/patchwork/submission.html
@@ -112,6 +112,16 @@
</td>
</tr>
{% endif %}
+{% if submission.series.metadata.all %}
+ <tr>
+ <th>Metadata</th>
+ <td>
+{% for entry in submission.series.metadata.all %}
+ <strong>{{ entry.key }}:</strong> {{ entry.value|metadata_value }}{% if not forloop.last %} | {% endif %}
+{% endfor %}
+ </td>
+ </tr>
+{% endif %}
{% if submission.related %}
<tr>
<th>Related</th>
diff --git a/patchwork/templatetags/utils.py b/patchwork/templatetags/utils.py
index 78c0aac80fc8..198fa0468e7c 100644
--- a/patchwork/templatetags/utils.py
+++ b/patchwork/templatetags/utils.py
@@ -4,6 +4,7 @@
# SPDX-License-Identifier: GPL-2.0-or-later
from django import template
+from django.utils.html import format_html
register = template.Library()
@@ -16,3 +17,11 @@ def verbose_name_plural(obj):
@register.simple_tag
def is_editable(obj, user):
return obj.is_editable(user)
+
+
+@register.filter
+def metadata_value(value):
+ s = str(value)
+ if s.startswith(('http://', 'https://')):
+ return format_html('<a href="{}">{}</a>', s, s)
+ return format_html('<code>{}</code>', s)
diff --git a/patchwork/tests/test_series.py b/patchwork/tests/test_series.py
index e5f60e3ae62c..dadf34e47a51 100644
--- a/patchwork/tests/test_series.py
+++ b/patchwork/tests/test_series.py
@@ -1081,3 +1081,42 @@ class SeriesDependencyTestCase(SeriesDependencyBase):
self.assertEqual(series2.dependencies.count(), 1)
self.assertEqual(series3.dependencies.count(), 2)
self.assertEqual(series3.dependents.count(), 0)
+
+
+class SeriesMetadataTest(TestCase):
+ def test_create_metadata(self):
+ series = utils.create_series()
+ meta = models.SeriesMetadata.objects.create(
+ series=series, key='github_pr', value='42'
+ )
+ self.assertEqual(str(meta), 'github_pr=42')
+
+ def test_unique_key_per_series(self):
+ from django.db import IntegrityError
+
+ series = utils.create_series()
+ models.SeriesMetadata.objects.create(
+ series=series, key='github_pr', value='42'
+ )
+ with self.assertRaises(IntegrityError):
+ models.SeriesMetadata.objects.create(
+ series=series, key='github_pr', value='99'
+ )
+
+ def test_lookup_by_key_and_value(self):
+ series = utils.create_series()
+ models.SeriesMetadata.objects.create(
+ series=series, key='github_pr', value='42'
+ )
+ found = models.SeriesMetadata.objects.get(key='github_pr', value='42')
+ self.assertEqual(found.series, series)
+
+ def test_multiple_keys(self):
+ series = utils.create_series()
+ models.SeriesMetadata.objects.create(
+ series=series, key='github_pr', value='42'
+ )
+ models.SeriesMetadata.objects.create(
+ series=series, key='github_branch', value='pwforge/1a2b/fix'
+ )
+ self.assertEqual(series.metadata.count(), 2)