mbox series
Message ID20260604141826.2998337-5-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 992951BC434F
	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 04/16] series: add metadata key-value store
Date: Thu,  4 Jun 2026 16:18:13 +0200
Message-ID: <20260604141826.2998337-5-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,04/16] series: add metadata key-value store

Add a SeriesMetadata model for attaching arbitrary key-value pairs to
patch series. This will be used by forge backends to store references
such as PR numbers and branch names (e.g. github_pr=42,
github_branch=pwforge/1a2b/fix).

The key field is indexed for fast lookups by metadata type. The value
field is also indexed to support reverse lookups (e.g. finding which
series is linked to a given PR number).

Signed-off-by: Robin Jarry <robin@jarry.cc>
---
 patchwork/admin.py                            |  9 ++++
 patchwork/migrations/0050_series_metadata.py  | 41 +++++++++++++++++++
 patchwork/models.py                           | 17 ++++++++
 patchwork/templates/patchwork/submission.html | 10 +++++
 patchwork/templatetags/utils.py               |  9 ++++
 patchwork/tests/test_series.py                | 39 ++++++++++++++++++
 6 files changed, 125 insertions(+)
 create mode 100644 patchwork/migrations/0050_series_metadata.py

Patch

mbox series
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)