diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5fb784df3..7ea0fff20 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,23 +1,30 @@ --- name: CI on: - - push - - pull_request + push: + branches: + - main + - stable/* + pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: lint: name: Run linters runs-on: ubuntu-latest steps: - name: Checkout source code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.13" + cache: 'pip' - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v6 with: - go-version: "1.21" + go-version: "1.24" - name: Install dependencies run: python -m pip install tox - name: Run tox @@ -40,7 +47,7 @@ jobs: MYSQL_ROOT_PASSWORD: root-${{ github.run_id }} services: postgres: - image: postgres:latest + image: postgres:17 env: POSTGRES_DB: ${{ env.DATABASE_NAME }} POSTGRES_PASSWORD: ${{ env.DATABASE_PASSWORD }} @@ -53,7 +60,7 @@ jobs: --health-timeout 5s --health-retries 5 mysql: - image: mysql:latest + image: mysql:8.4 env: MYSQL_DATABASE: ${{ env.DATABASE_NAME }} MYSQL_USER: ${{ env.DATABASE_USER }} @@ -68,9 +75,9 @@ jobs: --health-retries 5 steps: - name: Checkout source code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} - name: Install Python dependencies @@ -99,19 +106,22 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout source code - uses: actions/checkout@v3 + uses: actions/checkout@v6 with: fetch-depth: 0 + - name: Fetch all branches + run: git fetch --all - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.13" + cache: 'pip' - name: Install dependencies run: python -m pip install tox - name: Build docs (via tox) run: tox -e docs - name: Archive build results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v7 with: name: html-docs-build path: docs/_build/html @@ -126,13 +136,14 @@ jobs: COMPOSE_FILE: ${{ matrix.db == 'mysql' && 'docker-compose.yml' || (matrix.db == 'postgres' && 'docker-compose-pg.yml') || 'docker-compose-sqlite3.yml' }} steps: - name: Checkout source code - uses: actions/checkout@v3 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: "3.13" + cache: 'pip' - name: Build docker-compose service run: | docker compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g) @@ -155,7 +166,7 @@ jobs: docker compose exec -T -- db \ sh -c "exec mysql -uroot -p\"\${MYSQL_ROOT_PASSWORD}\" -e \"GRANT ALL ON \\\`test\\_\${MYSQL_DATABASE}%\\\`.* to '\${MYSQL_USER}'@'%'; FLUSH PRIVILEGES;\"" - name: Run unittest - run: docker compose run -T --rm web tox + run: docker compose run -T --rm web tox -e pep8,docs,py39-django42,py313-django51 - name: Test normal startup run: | docker compose up --detach diff --git a/.readthedocs.yaml b/.readthedocs.yaml index e19fe966e..355742bbd 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,14 +1,15 @@ --- version: 2 +sphinx: + builder: "dirhtml" + configuration: docs/conf.py python: install: - requirements: docs/requirements.txt -sphinx: - builder: "dirhtml" build: - os: "ubuntu-22.04" + os: "ubuntu-24.04" tools: - python: "3.11" + python: "3.14" jobs: post_checkout: - git fetch --unshallow diff --git a/README.rst b/README.rst index ebde1f363..002fb5ea1 100644 --- a/README.rst +++ b/README.rst @@ -6,10 +6,6 @@ Patchwork :target: https://pyup.io/repos/github/getpatchwork/patchwork/ :alt: Requirements Status -.. image:: https://codecov.io/gh/getpatchwork/patchwork/branch/main/graph/badge.svg - :target: https://codecov.io/gh/getpatchwork/patchwork - :alt: Codecov - .. image:: https://github.com/getpatchwork/patchwork/actions/workflows/ci.yaml/badge.svg :target: https://github.com/getpatchwork/patchwork/actions/workflows/ci.yaml :alt: Build Status diff --git a/docker-compose-pg.yml b/docker-compose-pg.yml index 44bc3ec0a..3e1798783 100644 --- a/docker-compose-pg.yml +++ b/docker-compose-pg.yml @@ -1,7 +1,7 @@ --- services: db: - image: postgres:latest + image: postgres:17 volumes: - ./tools/docker/db/postdata:/var/lib/postgresql/data environment: diff --git a/docker-compose.yml b/docker-compose.yml index 73f080a49..936818bf6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ --- services: db: - image: mysql:latest + image: mysql:8.4 volumes: - ./tools/docker/db/data:/var/lib/mysql environment: diff --git a/htdocs/css/style.css b/htdocs/css/style.css index 9156aa6ee..c4025c050 100644 --- a/htdocs/css/style.css +++ b/htdocs/css/style.css @@ -17,7 +17,7 @@ h2 a, h2 span { } pre { - line-height: 110%; + line-height: normal; background-color: white; border-radius: 0; } @@ -56,6 +56,34 @@ pre { color: #999; } +ul.dropdown-menu > li > form { + display: block; + width: 100%; +} + +ul.dropdown-menu > li > form > button { + /* taken from bootstrap's styling for '.dropdown-menu > li > a' */ + background: none; + border: none; + cursor: pointer; + display: block; + padding: 3px 20px; + clear: both; + font-weight: 400; + line-height: 1.42857143; + color: #333; + white-space: nowrap; + width: 100%; + text-align: left; +} + +ul.dropdown-menu > li > form > button:hover { + /* taken from bootstrap's styling for '.dropdown-menu > li > a:hover' */ + color: #262626; + text-decoration: none; + background-color: #f5f5f5; +} + form { padding: 0em; margin: 0em; @@ -354,15 +382,15 @@ button[class^=comment-action] { } .quote { - color: #007f00; + color: #365cb5; } -span.p_header { color: #2e8b57; font-weight: bold; } -span.p_chunk { color: #a52a2a; font-weight: bold; } -span.p_context { color: #a020f0; } -span.p_add { color: #008b8b; } -span.p_del { color: #6a5acd; } -span.p_mod { color: #0000ff; } +span.p_header { font-weight: bold; } +span.p_chunk { color: #329fb0; font-weight: bold; } +span.p_context { } +span.p_add { color: #1b9d09; background-color: #edffed; } +span.p_del { color: #c80101; background-color: #ffe2e2; } +span.p_mod { color: #a020f0; } .acked-by { color: #2d4566; diff --git a/patchwork/management/commands/parsemail.py b/patchwork/management/commands/parsemail.py index bcb257fe9..2f90047a9 100644 --- a/patchwork/management/commands/parsemail.py +++ b/patchwork/management/commands/parsemail.py @@ -8,6 +8,7 @@ import sys from django.core.management import base +from django.db import transaction from patchwork.parser import parse_mail from patchwork.parser import DuplicateMailError @@ -57,7 +58,8 @@ def handle(self, *args, **options): # broken email (ValueError): 1 (this could be noisy, if it's an issue # we could use a different return code) try: - result = parse_mail(mail, options['list_id']) + with transaction.atomic(): + result = parse_mail(mail, options['list_id']) if result is None: logger.warning('Nothing added to database') except DuplicateMailError as exc: diff --git a/patchwork/models.py b/patchwork/models.py index 4e5afc4ba..89ac23bc0 100644 --- a/patchwork/models.py +++ b/patchwork/models.py @@ -5,7 +5,6 @@ # SPDX-License-Identifier: GPL-2.0-or-later from collections import Counter -from collections import OrderedDict import datetime import random import re @@ -308,8 +307,6 @@ def with_tag_counts(self, project=None): # Project, and share the project.tags cache between all patch.project # references. qs = self.prefetch_related('project') - select = OrderedDict() - select_params = [] # All projects have the same tags, so we're good to go here if project: @@ -317,16 +314,24 @@ def with_tag_counts(self, project=None): else: tags = Tag.objects.all() + # Annotate the count of each Tag in a column for tag in tags: - select[tag.attr_name] = ( - 'coalesce(' - '(SELECT count FROM patchwork_patchtag' - ' WHERE patchwork_patchtag.patch_id=patchwork_patch.id' - ' AND patchwork_patchtag.tag_id=%s), 0)' + qs = qs.annotate( + **{ + tag.attr_name: models.Sum( + models.Case( + models.When( + patchtag__tag_id=tag.id, + then=models.F('patchtag__count'), + ), + default=models.Value(0), + output_field=models.IntegerField(), + ) + ) + } ) - select_params.append(tag.id) - return qs.extra(select=select, select_params=select_params) + return qs class PatchManager(models.Manager): diff --git a/patchwork/notifications.py b/patchwork/notifications.py index 6f33f8992..4dcfb5a80 100644 --- a/patchwork/notifications.py +++ b/patchwork/notifications.py @@ -73,6 +73,20 @@ def delete_notifications(): 'patchwork/mails/patch-change-notification.txt', context ) + # Got one case where recipient.email == "<>". That causes Django to fail with: + # File "/usr/lib/python3/dist-packages/django/core/mail/message.py", line 99, in sanitize_address + # address_parts = nm + localpart + domain + # ~~~^~~~~~~~~~~ + # TypeError: can only concatenate str (not "NoneType") to str + if ( + not recipient.email + or recipient.email == '<>' + or recipient.email == '' + ): + errors.append((recipient, 'Invalid recipient')) + delete_notifications() + continue + message = EmailMessage( subject=subject, body=content, diff --git a/patchwork/parser.py b/patchwork/parser.py index 09a53a08e..b03db4363 100644 --- a/patchwork/parser.py +++ b/patchwork/parser.py @@ -1256,20 +1256,18 @@ def parse_mail(mail, list_id=None): # later one. for ref in refs + [msgid]: ref = ref[:255] - # we don't want duplicates - try: - # we could have a ref to a previous series. - # (For example, a series sent in reply to - # another series.) That should not create a - # series ref for this series, so check for the - # msg-id only, not the msg-id/series pair. - SeriesReference.objects.get( - msgid=ref, project=project - ) - except SeriesReference.DoesNotExist: - SeriesReference.objects.create( - msgid=ref, project=project, series=series - ) + # We could have a ref to a previous series. (For + # example, a series sent in reply to another + # series.) That should not create a series ref for + # this series, so check for the msg-id only, not + # the msg-id/series pair. Use get_or_create to + # avoid races when multiple parsemail processes run + # in parallel. + SeriesReference.objects.get_or_create( + msgid=ref, + project=project, + defaults={'series': series}, + ) # attempt to pull the series in again, raising an # exception if we lost the race when creating a series @@ -1353,8 +1351,8 @@ def parse_mail(mail, list_id=None): # we don't save the in-reply-to or references fields # for a cover letter, as they can't refer to the same # series - SeriesReference.objects.create( - msgid=msgid, project=project, series=series + SeriesReference.objects.get_or_create( + msgid=msgid, project=project, defaults={'series': series} ) with transaction.atomic(): diff --git a/patchwork/templates/patchwork/submission.html b/patchwork/templates/patchwork/submission.html index 85e7be4b4..e924934f6 100644 --- a/patchwork/templates/patchwork/submission.html +++ b/patchwork/templates/patchwork/submission.html @@ -262,7 +262,7 @@
- {{ submission|commentsyntax }}
+{{ submission|commentsyntax }}
diff --git a/patchwork/templatetags/syntax.py b/patchwork/templatetags/syntax.py
index 64773e5ab..d5cf405ba 100644
--- a/patchwork/templatetags/syntax.py
+++ b/patchwork/templatetags/syntax.py
@@ -47,6 +47,10 @@ def _compile(value):
_span = '%s'
+_comment_link_re = re.compile(r'(https|http|git|ftp)://[^<)\s]+', re.I)
+
+_link = '%s'
+
@register.filter
def patchsyntax(patch):
@@ -74,5 +78,8 @@ def commentsyntax(submission):
for r, cls in _comment_span_res:
content = r.sub(lambda x: _span % (cls, x.group(0)), content)
+ content = _comment_link_re.sub(
+ lambda x: _link % (x.group(0), x.group(0)), content
+ )
return mark_safe(content)
diff --git a/patchwork/tests/test_version.py b/patchwork/tests/test_version.py
index 8e512084c..9cbb13051 100644
--- a/patchwork/tests/test_version.py
+++ b/patchwork/tests/test_version.py
@@ -32,4 +32,9 @@ def test_validate_version(self):
# if the tag is missing from one, it should be missing from the other
# (and vice versa)
- self.assertEqual(bool(str_match.group(1)), bool(git_match.group(1)))
+ self.assertEqual(
+ bool(str_match.group(1)),
+ bool(git_match.group(1)),
+ f'mismatch between git and version.txt post-release metadata: '
+ f'git={git_match.group(1)!r}, version.txt={str_match.group(1)!r}',
+ )
diff --git a/releasenotes/notes/make-diff-colors-more-accessible-82eda58a89984d46.yaml b/releasenotes/notes/make-diff-colors-more-accessible-82eda58a89984d46.yaml
new file mode 100644
index 000000000..f65995e51
--- /dev/null
+++ b/releasenotes/notes/make-diff-colors-more-accessible-82eda58a89984d46.yaml
@@ -0,0 +1,5 @@
+---
+other:
+ - |
+ The patch diff color palette was modified to make it more accessible for
+ all users, including those with common color deficiencies.
diff --git a/releasenotes/notes/parsemail-race-fix-e5f6g7h8i9j0k1l2.yaml b/releasenotes/notes/parsemail-race-fix-e5f6g7h8i9j0k1l2.yaml
new file mode 100644
index 000000000..f08698195
--- /dev/null
+++ b/releasenotes/notes/parsemail-race-fix-e5f6g7h8i9j0k1l2.yaml
@@ -0,0 +1,5 @@
+---
+fixes:
+ - |
+ Fix a race condition in SeriesReference creation during concurrent
+ email delivery.
diff --git a/releasenotes/notes/parsemail-transaction-d4e5f6g7h8i9j0k1.yaml b/releasenotes/notes/parsemail-transaction-d4e5f6g7h8i9j0k1.yaml
new file mode 100644
index 000000000..5a224ca23
--- /dev/null
+++ b/releasenotes/notes/parsemail-transaction-d4e5f6g7h8i9j0k1.yaml
@@ -0,0 +1,6 @@
+---
+fixes:
+ - |
+ Wrap the entire ``parse_mail()`` function in a single database
+ transaction to prevent partial state from being visible to
+ concurrent readers.
diff --git a/templates/base.html b/templates/base.html
index 747da5922..9519ecc55 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -93,7 +93,12 @@