Add a SeriesMetadata model to store arbitrary key-value metadata on
series for external integrations (e.g. linking to a GitHub PR or a CI
job). Each entry is a separate row with a unique key per series rather
than a JSON blob, making it easier to query and index individual keys.

Both key and value columns are indexed. The API supports filtering
series by metadata via ?metadata_key=foo&metadata_value=bar query
parameters, either independently or combined.

The REST API presents metadata as a flat dict for convenience.
Maintainers can update metadata via PATCH with a JSON object. Setting a
key to null deletes it. The API contract is part of version 1.4.

Show the series metadata key/value pairs on the patch detail page
when metadata is present. This allows seeing external integration
data (e.g. forge PR references) directly in the web UI.

Signed-off-by: Robin Jarry <[email protected]>
---
 docs/api/schemas/latest/patchwork.yaml        | 130 +++++++++++++++++
 docs/api/schemas/patchwork.j2                 | 134 ++++++++++++++++++
 docs/api/schemas/v1.4/patchwork.yaml          | 130 +++++++++++++++++
 patchwork/admin.py                            |   7 +
 patchwork/api/filters.py                      |   8 ++
 patchwork/api/series.py                       |  49 ++++++-
 patchwork/migrations/0050_series_metadata.py  |  44 ++++++
 patchwork/models.py                           |  27 ++++
 patchwork/templates/patchwork/submission.html |  10 ++
 patchwork/templatetags/utils.py               |   9 ++
 patchwork/tests/unit/api/test_series.py       |  40 +++++-
 .../series-metadata-b2c3d4e5f6g7h8i9.yaml     |  15 ++
 12 files changed, 591 insertions(+), 12 deletions(-)
 create mode 100644 patchwork/migrations/0050_series_metadata.py
 create mode 100644 releasenotes/notes/series-metadata-b2c3d4e5f6g7h8i9.yaml

diff --git a/docs/api/schemas/latest/patchwork.yaml 
b/docs/api/schemas/latest/patchwork.yaml
index eb7dcd382fc8..bd4e4cea4b6c 100644
--- a/docs/api/schemas/latest/patchwork.yaml
+++ b/docs/api/schemas/latest/patchwork.yaml
@@ -1401,6 +1401,18 @@ paths:
           schema:
             title: ''
             type: string
+        - in: query
+          name: metadata_key
+          description: A metadata key to filter series by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: metadata_value
+          description: A metadata value to filter series by.
+          schema:
+            title: ''
+            type: string
       responses:
         '200':
           description: 'List of series'
@@ -1445,6 +1457,82 @@ paths:
                 $ref: '#/components/schemas/Error'
       tags:
         - series
+    patch:
+      summary: Update a series (partial).
+      description:
+        Partially update an existing series.
+        You must be a maintainer of the project that the series belongs to.
+      operationId: series_partial_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Series'
+      responses:
+        '200':
+          description: 'Updated series'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Series'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorSeriesUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - series
+    put:
+      summary: Update a series.
+      description:
+        Update an existing series.
+        You must be a maintainer of the project that the series belongs to.
+      operationId: series_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Series'
+      responses:
+        '200':
+          description: 'Updated series'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Series'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorSeriesUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - series
   /api/users:
     get:
       summary: List users.
@@ -1700,6 +1788,14 @@ components:
         application/json:
           schema:
             $ref: '#/components/schemas/CommentUpdate'
+    Series:
+      required: true
+      description: |
+        A series.
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/SeriesUpdate'
     Webhook:
       required: true
       description: |
@@ -2854,6 +2950,28 @@ components:
             format: url
           readOnly: true
           uniqueItems: true
+        metadata:
+          title: Metadata
+          description: |
+            Arbitrary key-value metadata for external integrations.
+          type: object
+          additionalProperties:
+            type: string
+    SeriesUpdate:
+      type: object
+      title: Series update
+      description: |
+        The fields to set on an existing series.
+      properties:
+        metadata:
+          title: Metadata
+          description: |
+            Arbitrary key-value metadata. Set a key to null to delete it.
+          type: object
+          additionalProperties:
+            type:
+              - 'null'
+              - 'string'
     User:
       type: object
       title: User
@@ -3449,6 +3567,18 @@ components:
           type: array
           items:
             type: string
+    ErrorSeriesUpdate:
+      type: object
+      title: A series update error.
+      description: |
+        A mapping of field names to validation failures.
+      properties:
+        metadata:
+          title: Metadata
+          type: array
+          items:
+            type: string
+          readOnly: true
     ErrorWebhookCreateUpdate:
       type: object
       title: A webhook creation or update error.
diff --git a/docs/api/schemas/patchwork.j2 b/docs/api/schemas/patchwork.j2
index 56c72158e0b2..e05c1ec2be30 100644
--- a/docs/api/schemas/patchwork.j2
+++ b/docs/api/schemas/patchwork.j2
@@ -1428,6 +1428,20 @@ paths:
           schema:
             title: ''
             type: string
+{% if version >= (1, 4) %}
+        - in: query
+          name: metadata_key
+          description: A metadata key to filter series by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: metadata_value
+          description: A metadata value to filter series by.
+          schema:
+            title: ''
+            type: string
+{% endif %}
       responses:
         '200':
           description: 'List of series'
@@ -1472,6 +1486,84 @@ paths:
                 $ref: '#/components/schemas/Error'
       tags:
         - series
+{% if version >= (1, 4) %}
+    patch:
+      summary: Update a series (partial).
+      description:
+        Partially update an existing series.
+        You must be a maintainer of the project that the series belongs to.
+      operationId: series_partial_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Series'
+      responses:
+        '200':
+          description: 'Updated series'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Series'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorSeriesUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - series
+    put:
+      summary: Update a series.
+      description:
+        Update an existing series.
+        You must be a maintainer of the project that the series belongs to.
+      operationId: series_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Series'
+      responses:
+        '200':
+          description: 'Updated series'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Series'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorSeriesUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - series
+{% endif %}
   /api/{{ version_url }}users:
     get:
       summary: List users.
@@ -1744,6 +1836,14 @@ components:
             $ref: '#/components/schemas/CommentUpdate'
 {% endif %}
 {% if version >= (1, 4) %}
+    Series:
+      required: true
+      description: |
+        A series.
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/SeriesUpdate'
     Webhook:
       required: true
       description: |
@@ -2955,6 +3055,28 @@ components:
             format: url
           readOnly: true
           uniqueItems: true
+        metadata:
+          title: Metadata
+          description: |
+            Arbitrary key-value metadata for external integrations.
+          type: object
+          additionalProperties:
+            type: string
+    SeriesUpdate:
+      type: object
+      title: Series update
+      description: |
+        The fields to set on an existing series.
+      properties:
+        metadata:
+          title: Metadata
+          description: |
+            Arbitrary key-value metadata. Set a key to null to delete it.
+          type: object
+          additionalProperties:
+            type:
+              - 'null'
+              - 'string'
 {% endif %}
     User:
       type: object
@@ -3578,6 +3700,18 @@ components:
             type: string
 {% endif %}
 {% if version >= (1, 4) %}
+    ErrorSeriesUpdate:
+      type: object
+      title: A series update error.
+      description: |
+        A mapping of field names to validation failures.
+      properties:
+        metadata:
+          title: Metadata
+          type: array
+          items:
+            type: string
+          readOnly: true
     ErrorWebhookCreateUpdate:
       type: object
       title: A webhook creation or update error.
diff --git a/docs/api/schemas/v1.4/patchwork.yaml 
b/docs/api/schemas/v1.4/patchwork.yaml
index d15bf24e000b..7d94e5bd88f9 100644
--- a/docs/api/schemas/v1.4/patchwork.yaml
+++ b/docs/api/schemas/v1.4/patchwork.yaml
@@ -1401,6 +1401,18 @@ paths:
           schema:
             title: ''
             type: string
+        - in: query
+          name: metadata_key
+          description: A metadata key to filter series by.
+          schema:
+            title: ''
+            type: string
+        - in: query
+          name: metadata_value
+          description: A metadata value to filter series by.
+          schema:
+            title: ''
+            type: string
       responses:
         '200':
           description: 'List of series'
@@ -1445,6 +1457,82 @@ paths:
                 $ref: '#/components/schemas/Error'
       tags:
         - series
+    patch:
+      summary: Update a series (partial).
+      description:
+        Partially update an existing series.
+        You must be a maintainer of the project that the series belongs to.
+      operationId: series_partial_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Series'
+      responses:
+        '200':
+          description: 'Updated series'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Series'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorSeriesUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - series
+    put:
+      summary: Update a series.
+      description:
+        Update an existing series.
+        You must be a maintainer of the project that the series belongs to.
+      operationId: series_update
+      security:
+        - basicAuth: []
+        - apiKeyAuth: []
+      requestBody:
+        $ref: '#/components/requestBodies/Series'
+      responses:
+        '200':
+          description: 'Updated series'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Series'
+        '400':
+          description: 'Invalid request'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ErrorSeriesUpdate'
+        '403':
+          description: 'Forbidden'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+        '404':
+          description: 'Not found'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Error'
+      tags:
+        - series
   /api/1.4/users:
     get:
       summary: List users.
@@ -1700,6 +1788,14 @@ components:
         application/json:
           schema:
             $ref: '#/components/schemas/CommentUpdate'
+    Series:
+      required: true
+      description: |
+        A series.
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/SeriesUpdate'
     Webhook:
       required: true
       description: |
@@ -2854,6 +2950,28 @@ components:
             format: url
           readOnly: true
           uniqueItems: true
+        metadata:
+          title: Metadata
+          description: |
+            Arbitrary key-value metadata for external integrations.
+          type: object
+          additionalProperties:
+            type: string
+    SeriesUpdate:
+      type: object
+      title: Series update
+      description: |
+        The fields to set on an existing series.
+      properties:
+        metadata:
+          title: Metadata
+          description: |
+            Arbitrary key-value metadata. Set a key to null to delete it.
+          type: object
+          additionalProperties:
+            type:
+              - 'null'
+              - 'string'
     User:
       type: object
       title: User
@@ -3449,6 +3567,18 @@ components:
           type: array
           items:
             type: string
+    ErrorSeriesUpdate:
+      type: object
+      title: A series update error.
+      description: |
+        A mapping of field names to validation failures.
+      properties:
+        metadata:
+          title: Metadata
+          type: array
+          items:
+            type: string
+          readOnly: true
     ErrorWebhookCreateUpdate:
       type: object
       title: A webhook creation or update error.
diff --git a/patchwork/admin.py b/patchwork/admin.py
index 26de8baefb36..4d9ee7aad4aa 100644
--- a/patchwork/admin.py
+++ b/patchwork/admin.py
@@ -21,6 +21,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
@@ -167,6 +168,12 @@ class SeriesReferenceAdmin(admin.ModelAdmin):
     model = SeriesReference
 
 
[email protected](SeriesMetadata)
+class SeriesMetadataAdmin(admin.ModelAdmin):
+    model = SeriesMetadata
+    list_display = ('series', 'key', 'value')
+
+
 @admin.register(Check)
 class CheckAdmin(admin.ModelAdmin):
     list_display = (
diff --git a/patchwork/api/filters.py b/patchwork/api/filters.py
index e332c5316c2e..7a59073f23c2 100644
--- a/patchwork/api/filters.py
+++ b/patchwork/api/filters.py
@@ -165,6 +165,14 @@ class TimestampMixin(BaseFilterSet):
 class SeriesFilterSet(TimestampMixin, BaseFilterSet):
     submitter = PersonFilter(queryset=Person.objects.all(), distinct=False)
     project = ProjectFilter(queryset=Project.objects.all(), distinct=False)
+    metadata_key = CharFilter(
+        field_name='metadata_entry__key',
+        label='Metadata key',
+    )
+    metadata_value = CharFilter(
+        field_name='metadata_entry__value',
+        label='Metadata value',
+    )
 
     class Meta:
         model = Series
diff --git a/patchwork/api/series.py b/patchwork/api/series.py
index c9f5f54bdbfd..c8acb12f578f 100644
--- a/patchwork/api/series.py
+++ b/patchwork/api/series.py
@@ -4,7 +4,7 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 
 from rest_framework.generics import ListAPIView
-from rest_framework.generics import RetrieveAPIView
+from rest_framework.generics import RetrieveUpdateAPIView
 from rest_framework.serializers import (
     SerializerMethodField,
     HyperlinkedRelatedField,
@@ -18,6 +18,7 @@ from patchwork.api.embedded import PatchSerializer
 from patchwork.api.embedded import PersonSerializer
 from patchwork.api.embedded import ProjectSerializer
 from patchwork.models import Series
+from patchwork.models import SeriesMetadata
 
 
 class SeriesSerializer(BaseHyperlinkedModelSerializer):
@@ -33,6 +34,7 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer):
     dependents = HyperlinkedRelatedField(
         read_only=True, view_name='api-series-detail', many=True
     )
+    metadata = SerializerMethodField()
 
     def get_web_url(self, instance):
         request = self.context.get('request')
@@ -42,15 +44,39 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer):
         request = self.context.get('request')
         return request.build_absolute_uri(instance.get_mbox_url())
 
+    def get_metadata(self, instance):
+        return {m.key: m.value for m in instance.metadata.all()}
+
     def to_representation(self, instance):
         if not instance.project.show_dependencies:
             for field in ('dependencies', 'dependents'):
                 if field in self.fields:
                     del self.fields[field]
 
-        data = super().to_representation(instance)
+        return super().to_representation(instance)
 
-        return data
+    def to_internal_value(self, data):
+        ret = super().to_internal_value(data)
+        if 'metadata' in data:
+            ret['metadata'] = data['metadata']
+        return ret
+
+    def update(self, instance, validated_data):
+        metadata = validated_data.pop('metadata', None)
+        instance = super().update(instance, validated_data)
+        if metadata is not None:
+            for key, value in metadata.items():
+                if value is None:
+                    SeriesMetadata.objects.filter(
+                        series=instance, key=key
+                    ).delete()
+                else:
+                    SeriesMetadata.objects.update_or_create(
+                        series=instance,
+                        key=key,
+                        defaults={'value': str(value)},
+                    )
+        return instance
 
     class Meta:
         model = Series
@@ -71,6 +97,7 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer):
             'patches',
             'dependencies',
             'dependents',
+            'metadata',
         )
         read_only_fields = (
             'date',
@@ -86,7 +113,7 @@ class SeriesSerializer(BaseHyperlinkedModelSerializer):
         )
         versioned_fields = {
             '1.1': ('web_url',),
-            '1.4': ('dependencies', 'dependents'),
+            '1.4': ('dependencies', 'dependents', 'metadata'),
         }
         extra_kwargs = {
             'url': {'view_name': 'api-series-detail'},
@@ -105,6 +132,7 @@ class SeriesMixin(object):
                 'cover_letter__project',
                 'dependencies',
                 'dependents',
+                'metadata',
             )
             .select_related('submitter', 'project')
         )
@@ -119,7 +147,16 @@ class SeriesList(SeriesMixin, ListAPIView):
     ordering = 'id'
 
 
-class SeriesDetail(SeriesMixin, RetrieveAPIView):
-    """Show a series."""
+class SeriesDetail(SeriesMixin, RetrieveUpdateAPIView):
+    """
+    get:
+    Show a series.
+
+    patch:
+    Update a series.
+
+    put:
+    Update a series.
+    """
 
     pass
diff --git a/patchwork/migrations/0050_series_metadata.py 
b/patchwork/migrations/0050_series_metadata.py
new file mode 100644
index 000000000000..f3fccf10a02f
--- /dev/null
+++ b/patchwork/migrations/0050_series_metadata.py
@@ -0,0 +1,44 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('patchwork', '0049_webhook'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='webhook',
+            options={'ordering': ['id']},
+        ),
+        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',
+                        related_query_name='metadata_entry',
+                        to='patchwork.series',
+                    ),
+                ),
+            ],
+            options={
+                'unique_together': {('series', 'key')},
+                'ordering': ['key'],
+            },
+        ),
+    ]
diff --git a/patchwork/models.py b/patchwork/models.py
index ed89aa7e26c7..f3f982465cf9 100644
--- a/patchwork/models.py
+++ b/patchwork/models.py
@@ -972,6 +972,13 @@ class Series(FilenameMixin, models.Model):
 
         return patch
 
+    def is_editable(self, user):
+        if not user.is_authenticated:
+            return False
+        if self.project.is_editable(user):
+            return True
+        return False
+
     def get_absolute_url(self):
         # TODO(stephenfin): We really need a proper series view
         return reverse(
@@ -1013,6 +1020,26 @@ class SeriesReference(models.Model):
         unique_together = [('project', 'msgid')]
 
 
+class SeriesMetadata(models.Model):
+    """A single key-value metadata entry for a series."""
+
+    series = models.ForeignKey(
+        Series,
+        related_name='metadata',
+        related_query_name='metadata_entry',
+        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 f'{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 cd74491c0e92..7be7f1966d80 100644
--- a/patchwork/templates/patchwork/submission.html
+++ b/patchwork/templates/patchwork/submission.html
@@ -97,6 +97,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)
+
+
[email protected]
+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/unit/api/test_series.py 
b/patchwork/tests/unit/api/test_series.py
index 80887d48d3b5..3b5ce6aedf3e 100644
--- a/patchwork/tests/unit/api/test_series.py
+++ b/patchwork/tests/unit/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')
@@ -251,8 +251,8 @@ class TestSeriesAPI(utils.APITestCase):
         with self.assertRaises(NoReverseMatch):
             self.client.get(self.api_url('foo'))
 
-    def test_create_update_delete(self):
-        """Ensure creates, updates and deletes aren't allowed"""
+    def test_create_delete(self):
+        """Ensure creates and deletes aren't allowed."""
         user = create_maintainer()
         user.is_superuser = True
         user.save()
@@ -263,8 +263,36 @@ class TestSeriesAPI(utils.APITestCase):
 
         series = create_series()
 
-        resp = self.client.patch(self.api_url(series.id), {'name': 'Test'})
-        self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
-
         resp = self.client.delete(self.api_url(series.id))
         self.assertEqual(status.HTTP_405_METHOD_NOT_ALLOWED, resp.status_code)
+
+    def test_update(self):
+        """Ensure maintainers can update series metadata."""
+        project = create_project()
+        user = create_maintainer(project=project)
+        self.client.authenticate(user=user)
+
+        series = create_series(project=project)
+
+        resp = self.client.patch(
+            self.api_url(series.id),
+            {'metadata': {'github': 'owner/repo#42'}},
+            validate_request=False,
+            validate_response=False,
+        )
+        self.assertEqual(status.HTTP_200_OK, resp.status_code)
+        self.assertEqual({'github': 'owner/repo#42'}, resp.data['metadata'])
+
+    def test_update_non_maintainer(self):
+        """Ensure non-maintainers cannot update series."""
+        series = create_series()
+        user = create_user()
+        self.client.authenticate(user=user)
+
+        resp = self.client.patch(
+            self.api_url(series.id),
+            {'metadata': {'test': 'value'}},
+            validate_request=False,
+            validate_response=False,
+        )
+        self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
diff --git a/releasenotes/notes/series-metadata-b2c3d4e5f6g7h8i9.yaml 
b/releasenotes/notes/series-metadata-b2c3d4e5f6g7h8i9.yaml
new file mode 100644
index 000000000000..f1e759fc0ba1
--- /dev/null
+++ b/releasenotes/notes/series-metadata-b2c3d4e5f6g7h8i9.yaml
@@ -0,0 +1,15 @@
+---
+features:
+  - |
+    Series can now have arbitrary key-value metadata attached for external
+    integrations. Each metadata entry is stored as a separate row allowing
+    efficient querying and indexing. The patch detail page displays metadata
+    inline with URL values rendered as clickable links.
+api:
+  - |
+    Add ``metadata`` field to the series endpoint (v1.4). Metadata is presented
+    as a JSON object. Maintainers can update metadata via PATCH, setting a key
+    to null deletes it.
+  - |
+    Add ``metadata_key`` and ``metadata_value`` query filters to the series
+    list endpoint for filtering by metadata entries.
-- 
2.54.0

_______________________________________________
Patchwork mailing list
[email protected]
https://lists.ozlabs.org/listinfo/patchwork

Reply via email to