Skip to content

Commit 726df40

Browse files
committed
management: Add relabel command
The command re-parses subject headers of patches from given projects and adds label information to the database. The main usecase is to refresh label information on existing patches after adding a new label to the project.
1 parent 659c429 commit 726df40

2 files changed

Lines changed: 153 additions & 0 deletions

File tree

‎docs/deployment/management.rst‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,30 @@ patches for these new tags.
192192
.. option:: patch_id
193193
194194
a patch ID number. If not supplied, all patches will be updated.
195+
196+
.. _command-relabel:
197+
198+
relabel
199+
~~~~~~~
200+
201+
.. program:: manage.py relabel
202+
203+
Relabel patches based on the subject line.
204+
205+
.. code-block:: shell
206+
207+
./manage.py relabel [PROJECT [PROJECT...]]
208+
209+
Patchwork extracts labels from square brackets at the beginning of the subject
210+
line, eg. ``[label1, label2] Patch title``.
211+
212+
Only labels created in the admin interface will be recognized.
213+
The script prioritizes project-specific labels if both project-specific and
214+
general labels with the same name exist.
215+
216+
The script will not remove labels manually added to patches, but will re-add
217+
labels manually removed from patches.
218+
219+
.. option:: PROJECT
220+
221+
list of IDs of projects to update. Relabels all projects if none specified.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Patchwork - automated patch tracking system
2+
# Copyright (C) 2026, The Linux Foundation
3+
#
4+
# SPDX-License-Identifier: GPL-2.0-or-later
5+
6+
from django.core.management.base import BaseCommand
7+
8+
from email.parser import HeaderParser
9+
10+
from patchwork.models import Label, Patch
11+
from patchwork.parser import clean_subject
12+
13+
14+
# Prepares a dictionary that maps from project id to a list of
15+
# labels of this project (including global labels, if no local version exists)
16+
def get_labels_per_project():
17+
global_labels = list(Label.objects.filter(project_id=None).all())
18+
19+
labels_per_project = {}
20+
for label in Label.objects.filter(project_id__isnull=True).all():
21+
labels_list = labels_per_project.get(label.project_id, [])
22+
labels_list.append(label)
23+
labels_per_project[label.project_id] = labels_list
24+
25+
for project_labels in labels_per_project.values():
26+
local_label_names = [label.name for label in project_labels]
27+
for global_label in global_labels:
28+
if global_label.name not in local_label_names:
29+
project_labels.append(global_label)
30+
31+
return labels_per_project, global_labels
32+
33+
34+
# Parses stripped name and labels out of patch headers
35+
def get_name_and_labels(headers, labels):
36+
parser = HeaderParser()
37+
headers = parser.parsestr(headers)
38+
subject = headers['Subject']
39+
prefixes = []
40+
if subject is None:
41+
return None, None
42+
43+
name, prefixes, cleaned_prefixes = clean_subject(subject, labels)
44+
45+
return name, cleaned_prefixes
46+
47+
48+
class Command(BaseCommand):
49+
help = (
50+
'Update labels for existing patches based on the subject line. '
51+
'NOTE: Labels have to be created in the admin interface first.'
52+
)
53+
54+
def add_arguments(self, parser):
55+
parser.add_argument(
56+
'projects',
57+
metavar='PROJECT',
58+
nargs='*',
59+
help='list of listIDs of projects to be relabeld',
60+
)
61+
62+
def flush_patches(self):
63+
if len(self.patches_to_update) > 0:
64+
Patch.objects.bulk_update(self.patches_to_update, ['name'])
65+
self.patches_to_update = []
66+
self.patches_to_update_ids = set()
67+
68+
if len(self.labels_to_add) > 0:
69+
Patch.labels.through.objects.bulk_create(self.labels_to_add)
70+
self.labels_to_add = []
71+
72+
def update_patches(self, projects):
73+
self.patches_to_update = []
74+
self.patches_to_update_ids = set()
75+
self.labels_to_add = []
76+
77+
query = Patch.objects.all().only('id', 'headers', 'project_id')
78+
79+
if projects:
80+
query = query.filter(project__listid__in=projects)
81+
82+
count = query.count()
83+
84+
for i, patch in enumerate(query.iterator(chunk_size=1000)):
85+
project_labels = self.labels_per_project.get(
86+
patch.project_id, self.global_labels
87+
)
88+
89+
name, cleaned_prefixes = get_name_and_labels(
90+
patch.headers, [label.name for label in project_labels]
91+
)
92+
if name is None or cleaned_prefixes is None:
93+
continue
94+
95+
for label in project_labels:
96+
if (
97+
label.name in cleaned_prefixes
98+
and label not in patch.labels.all()
99+
):
100+
self.labels_to_add.append(
101+
Patch.labels.through(
102+
patch_id=patch.id, label_id=label.id
103+
)
104+
)
105+
if patch.id not in self.patches_to_update_ids:
106+
patch.name = name[:255]
107+
self.patches_to_update.append(patch)
108+
self.patches_to_update_ids.add(patch.id)
109+
110+
if (i % 100) == 0:
111+
self.stdout.write('%06d/%06d\r' % (i, count), ending='')
112+
self.stdout.flush()
113+
114+
if len(self.patches_to_update) >= 1000:
115+
self.flush_patches()
116+
117+
self.flush_patches()
118+
119+
def handle(self, *args, **options):
120+
self.labels_per_project, self.global_labels = get_labels_per_project()
121+
122+
self.stdout.write('\nupdating patches')
123+
124+
self.update_patches(options['projects'])
125+
126+
self.stdout.write('\ndone')

0 commit comments

Comments
 (0)