-
-
-
+
-
+
-
+
- {%if title %}
-
{{title}}
- {%endif%}
+ {%if title %}
+
{{title}}
+ {%endif%}
- {%if messages%}
- {%for m in messages%}
-
{{m}}
- {%endfor%}
- {%endif%}
+ {%if messages%}
+ {%for m in messages%}
+
{{m}}
+ {%endfor%}
+ {%endif%}
- {%block contents%}
- {%endblock%}
-
-
-
-
-
-
- {%block morescript%}{%endblock%}
-
+ {%block contents%}
+ {%endblock%}
+
+
+
+
+
+
+ {%block morescript%}{%endblock%}
+{%endlocalize%}
+
diff --git a/pgcommitfest/commitfest/templates/commitfest.html b/pgcommitfest/commitfest/templates/commitfest.html
index d20aa84d..ac1b778b 100644
--- a/pgcommitfest/commitfest/templates/commitfest.html
+++ b/pgcommitfest/commitfest/templates/commitfest.html
@@ -75,8 +75,10 @@
{{p.is_open|yesno:"Active patches,Closed patches"}}
- {%if cfb.failed > 0 or cfb.branch_status == 'failed' or cfb.branch_status == 'timeout' %}
+ {%if cfb.branch_status == 'failed' or cfb.branch_status == 'timeout' or cfb.failed_non_formatting > 0 %}
+ {%elif cfb.failed > 0 %}
+
{%elif cfb.completed < cfb.total %}
{%else%}
diff --git a/pgcommitfest/commitfest/templates/home.html b/pgcommitfest/commitfest/templates/home.html
index d0fbade9..80ff99ff 100644
--- a/pgcommitfest/commitfest/templates/home.html
+++ b/pgcommitfest/commitfest/templates/home.html
@@ -182,8 +182,10 @@
+ {%endif%}
diff --git a/pgcommitfest/commitfest/tests/__init__.py b/pgcommitfest/commitfest/tests/__init__.py
new file mode 100644
index 00000000..7d5a9d90
--- /dev/null
+++ b/pgcommitfest/commitfest/tests/__init__.py
@@ -0,0 +1 @@
+# Tests for the commitfest application
diff --git a/pgcommitfest/commitfest/tests/conftest.py b/pgcommitfest/commitfest/tests/conftest.py
new file mode 100644
index 00000000..9ce147e6
--- /dev/null
+++ b/pgcommitfest/commitfest/tests/conftest.py
@@ -0,0 +1,136 @@
+"""Shared test fixtures for commitfest tests."""
+
+from django.contrib.auth.models import User
+
+from datetime import date
+
+import pytest
+
+from pgcommitfest.commitfest.models import CommitFest
+
+
+@pytest.fixture
+def alice():
+ """Create test user Alice."""
+ return User.objects.create_user(
+ username="alice",
+ first_name="Alice",
+ last_name="Anderson",
+ email="alice@example.com",
+ )
+
+
+@pytest.fixture
+def bob():
+ """Create test user Bob."""
+ return User.objects.create_user(
+ username="b",
+ first_name="Bob",
+ last_name="Brown",
+ email="bob@example.com",
+ )
+
+
+@pytest.fixture
+def charlie():
+ """Create test user Charlie."""
+ return User.objects.create_user(
+ username="charlie",
+ first_name="Charlie",
+ last_name="Chen",
+ email="charlie@example.com",
+ )
+
+
+@pytest.fixture
+def dave():
+ """Create test user Dave."""
+ return User.objects.create_user(
+ username="dave",
+ first_name="Dave",
+ last_name="Davis",
+ email="dave@example.com",
+ )
+
+
+@pytest.fixture
+def users(alice, bob, charlie, dave):
+ """Create all test users and return as a dictionary."""
+ return {
+ "alice": alice,
+ "bob": bob,
+ "charlie": charlie,
+ "dave": dave,
+ }
+
+
+@pytest.fixture
+def open_cf():
+ """Create an open commitfest."""
+ return CommitFest.objects.create(
+ name="2025-01",
+ status=CommitFest.STATUS_OPEN,
+ startdate=date(2025, 1, 1),
+ enddate=date(2025, 1, 31),
+ draft=False,
+ )
+
+
+@pytest.fixture
+def in_progress_cf():
+ """Create an in-progress commitfest."""
+ return CommitFest.objects.create(
+ name="2024-11",
+ status=CommitFest.STATUS_INPROGRESS,
+ startdate=date(2024, 11, 1),
+ enddate=date(2024, 11, 30),
+ draft=False,
+ )
+
+
+@pytest.fixture
+def recent_closed_cf():
+ """Create a recently closed commitfest."""
+ return CommitFest.objects.create(
+ name="2024-09",
+ status=CommitFest.STATUS_CLOSED,
+ startdate=date(2024, 9, 1),
+ enddate=date(2024, 9, 30),
+ draft=False,
+ )
+
+
+@pytest.fixture
+def old_closed_cf():
+ """Create an old closed commitfest."""
+ return CommitFest.objects.create(
+ name="2024-07",
+ status=CommitFest.STATUS_CLOSED,
+ startdate=date(2024, 7, 1),
+ enddate=date(2024, 7, 31),
+ draft=False,
+ )
+
+
+@pytest.fixture
+def draft_cf():
+ """Create a draft commitfest."""
+ return CommitFest.objects.create(
+ name="2025-03-draft",
+ status=CommitFest.STATUS_OPEN,
+ startdate=date(2025, 3, 1),
+ enddate=date(2025, 3, 31),
+ draft=True,
+ )
+
+
+@pytest.fixture
+def commitfests(open_cf, in_progress_cf, recent_closed_cf, old_closed_cf, draft_cf):
+ """Create all test commitfests and return as a dictionary."""
+ return {
+ "open": open_cf,
+ "in_progress": in_progress_cf,
+ "recent_previous": recent_closed_cf,
+ "old_previous": old_closed_cf,
+ "draft": draft_cf,
+ }
diff --git a/pgcommitfest/commitfest/tests/test_apiv1.py b/pgcommitfest/commitfest/tests/test_apiv1.py
new file mode 100644
index 00000000..f3b94595
--- /dev/null
+++ b/pgcommitfest/commitfest/tests/test_apiv1.py
@@ -0,0 +1,46 @@
+import json
+
+import pytest
+
+pytestmark = pytest.mark.django_db
+
+
+def test_needs_ci_endpoint(client, commitfests):
+ """Test the /api/v1/commitfests/needs_ci endpoint returns correct data."""
+ response = client.get("/api/v1/commitfests/needs_ci")
+
+ # Check response metadata
+ assert response.status_code == 200
+ assert response["Content-Type"] == "application/json"
+ assert response["Access-Control-Allow-Origin"] == "*"
+
+ # Parse and compare response
+ data = json.loads(response.content)
+
+ expected = {
+ "commitfests": {
+ "open": {
+ "id": commitfests["open"].id,
+ "name": "2025-01",
+ "status": "Open",
+ "startdate": "2025-01-01",
+ "enddate": "2025-01-31",
+ },
+ "in_progress": {
+ "id": commitfests["in_progress"].id,
+ "name": "2024-11",
+ "status": "In Progress",
+ "startdate": "2024-11-01",
+ "enddate": "2024-11-30",
+ },
+ "draft": {
+ "id": commitfests["draft"].id,
+ "name": "2025-03-draft",
+ "status": "Open",
+ "startdate": "2025-03-01",
+ "enddate": "2025-03-31",
+ },
+ }
+ }
+
+ assert data == expected
diff --git a/pgcommitfest/commitfest/tests/test_lookups.py b/pgcommitfest/commitfest/tests/test_lookups.py
new file mode 100644
index 00000000..3dbcc3a6
--- /dev/null
+++ b/pgcommitfest/commitfest/tests/test_lookups.py
@@ -0,0 +1,208 @@
+import json
+from datetime import datetime
+
+import pytest
+
+from pgcommitfest.commitfest.models import Committer, Patch, PatchOnCommitFest, Topic
+
+pytestmark = pytest.mark.django_db
+
+
+@pytest.fixture
+def topic():
+ """Create a test topic."""
+ return Topic.objects.create(topic="General")
+
+
+@pytest.fixture
+def patches_with_users(users, open_cf, topic):
+ """Create patches with authors and reviewers in a commitfest."""
+ # Alice is an author on patch 1
+ patch1 = Patch.objects.create(name="Test Patch 1", topic=topic)
+ patch1.authors.add(users["alice"])
+ PatchOnCommitFest.objects.create(
+ patch=patch1, commitfest=open_cf, enterdate=datetime.now()
+ )
+
+ # Bob is a reviewer on patch 2
+ patch2 = Patch.objects.create(name="Test Patch 2", topic=topic)
+ patch2.reviewers.add(users["bob"])
+ PatchOnCommitFest.objects.create(
+ patch=patch2, commitfest=open_cf, enterdate=datetime.now()
+ )
+
+ # Dave is a committer on patch 3
+ dave_committer = Committer.objects.create(user=users["dave"])
+ patch3 = Patch.objects.create(
+ name="Test Patch 3", topic=topic, committer=dave_committer
+ )
+ PatchOnCommitFest.objects.create(
+ patch=patch3, commitfest=open_cf, enterdate=datetime.now()
+ )
+
+ # Charlie has no involvement in this commitfest
+ return {"patch1": patch1, "patch2": patch2, "patch3": patch3}
+
+
+def test_userlookup_without_cf_requires_login(client, alice):
+ """Test that userlookup without cf parameter requires login."""
+ response = client.get("/lookups/user/", {"query": "alice"})
+
+ assert response.status_code == 403
+ assert b"Login required" in response.content
+
+
+def test_userlookup_without_cf_works_when_logged_in(client, alice, bob):
+ """Test that userlookup without cf parameter works when logged in."""
+ client.force_login(bob)
+ response = client.get("/lookups/user/", {"query": "alice"})
+
+ assert response.status_code == 200
+ assert json.loads(response.content) == {
+ "values": [
+ {
+ "id": alice.id,
+ "value": "Alice Anderson (alice)",
+ }
+ ]
+ }
+
+
+def test_userlookup_with_cf_no_login_required(
+ client, alice, open_cf, patches_with_users
+):
+ """Test that userlookup with cf parameter works without login."""
+ response = client.get("/lookups/user/", {"query": "alice", "cf": open_cf.id})
+
+ assert response.status_code == 200
+ assert json.loads(response.content) == {
+ "values": [
+ {
+ "id": alice.id,
+ "value": "Alice Anderson (alice)",
+ }
+ ]
+ }
+
+
+def test_userlookup_with_cf_filters_to_commitfest_participants(
+ client, alice, bob, dave, open_cf, patches_with_users
+):
+ """Test that userlookup with cf parameter only returns users in that commitfest."""
+ # Search for all users with 'a' in their name
+ response = client.get("/lookups/user/", {"query": "a", "cf": open_cf.id})
+
+ assert response.status_code == 200
+ # Should return Alice and Dave (both involved) but not Charlie (has 'a' but not involved)
+ # Results are returned in order by user ID
+ data = json.loads(response.content)
+ # Sort by id to ensure consistent ordering
+ data["values"].sort(key=lambda x: x["id"])
+ expected_values = [
+ {
+ "id": alice.id,
+ "value": "Alice Anderson (alice)",
+ },
+ {
+ "id": dave.id,
+ "value": "Dave Davis (dave)",
+ },
+ ]
+ expected_values.sort(key=lambda x: x["id"])
+ assert data == {"values": expected_values}
+
+
+def test_userlookup_with_cf_includes_reviewers(
+ client, bob, open_cf, patches_with_users
+):
+ """Test that userlookup with cf parameter includes reviewers."""
+ response = client.get("/lookups/user/", {"query": "bob", "cf": open_cf.id})
+
+ assert response.status_code == 200
+ assert json.loads(response.content) == {
+ "values": [
+ {
+ "id": bob.id,
+ "value": "Bob Brown (b)",
+ }
+ ]
+ }
+
+
+def test_userlookup_with_cf_includes_committers(
+ client, dave, open_cf, patches_with_users
+):
+ """Test that userlookup with cf parameter includes committers."""
+ response = client.get("/lookups/user/", {"query": "dave", "cf": open_cf.id})
+
+ assert response.status_code == 200
+ assert json.loads(response.content) == {
+ "values": [
+ {
+ "id": dave.id,
+ "value": "Dave Davis (dave)",
+ }
+ ]
+ }
+
+
+def test_userlookup_excludes_uninvolved_users(
+ client, charlie, open_cf, patches_with_users
+):
+ """Test that users not involved in the commitfest are excluded."""
+ response = client.get("/lookups/user/", {"query": "charlie", "cf": open_cf.id})
+
+ assert response.status_code == 200
+ assert json.loads(response.content) == {"values": []}
+
+
+def test_userlookup_requires_query_parameter(client, commitfests):
+ """Test that userlookup returns 404 without query parameter."""
+ response = client.get("/lookups/user/")
+
+ assert response.status_code == 404
+
+
+def test_userlookup_searches_first_name(client, bob, open_cf, patches_with_users):
+ """Test that userlookup searches by first name."""
+ response = client.get("/lookups/user/", {"query": "Bob", "cf": open_cf.id})
+
+ assert response.status_code == 200
+ assert json.loads(response.content) == {
+ "values": [
+ {
+ "id": bob.id,
+ "value": "Bob Brown (b)",
+ }
+ ]
+ }
+
+
+def test_userlookup_searches_last_name(client, bob, open_cf, patches_with_users):
+ """Test that userlookup searches by last name."""
+ response = client.get("/lookups/user/", {"query": "Brown", "cf": open_cf.id})
+
+ assert response.status_code == 200
+ assert json.loads(response.content) == {
+ "values": [
+ {
+ "id": bob.id,
+ "value": "Bob Brown (b)",
+ }
+ ]
+ }
+
+
+def test_userlookup_case_insensitive(client, alice, open_cf, patches_with_users):
+ """Test that userlookup is case insensitive."""
+ response = client.get("/lookups/user/", {"query": "ALICE", "cf": open_cf.id})
+
+ assert response.status_code == 200
+ assert json.loads(response.content) == {
+ "values": [
+ {
+ "id": alice.id,
+ "value": "Alice Anderson (alice)",
+ }
+ ]
+ }
diff --git a/pgcommitfest/commitfest/views.py b/pgcommitfest/commitfest/views.py
index 6b3bd403..12cd8a6f 100644
--- a/pgcommitfest/commitfest/views.py
+++ b/pgcommitfest/commitfest/views.py
@@ -94,7 +94,7 @@ def home(request):
# Use existing cfs data instead of querying again
cf = cfs.get("in_progress") or cfs.get("open")
- form = CommitFestFilterForm(request.GET)
+ form = CommitFestFilterForm(request.GET, commitfest=cf)
patch_list = patchlist(request, cf, personalized=True)
if patch_list.redirect:
@@ -564,6 +564,7 @@ def patchlist(request, cf, personalized=False):
count(*) FILTER (WHERE task.status in ('COMPLETED', 'PAUSED')) as completed,
count(*) FILTER (WHERE task.status in ('CREATED', 'SCHEDULED', 'EXECUTING')) running,
count(*) FILTER (WHERE task.status in ('ABORTED', 'ERRORED', 'FAILED')) failed,
+ count(*) FILTER (WHERE task.status in ('ABORTED', 'ERRORED', 'FAILED') AND task.task_name != 'FormattingCheck') as failed_non_formatting,
count(*) total,
string_agg(task.task_name, ', ') FILTER (WHERE task.status in ('ABORTED', 'ERRORED', 'FAILED')) as failed_task_names,
branch.status as branch_status,
@@ -626,7 +627,7 @@ def commitfest(request, cfid):
# Generates a fairly expensive query, which we shouldn't do unless
# the user is logged in. XXX: Figure out how to avoid doing that..
- form = CommitFestFilterForm(request.GET)
+ form = CommitFestFilterForm(request.GET, commitfest=cf)
return render(
request,
@@ -686,6 +687,11 @@ def patches_by_messageid(messageid):
)
+# We require login for this page primarily so that the author/reviewer filter
+# boxes can always be searched. Since searching for users outside of a
+# commitfest requires users to be logged in to not make the data too easy to
+# scrape.
+@login_required
def global_search(request):
if "searchterm" not in request.GET:
return HttpResponseRedirect("/")
@@ -815,7 +821,7 @@ def global_search(request):
patch = patches[0]
return HttpResponseRedirect(f"/patch/{patch.id}/")
- # Use the existing filter form
+ # Use the existing filter form (no cf parameter, will require login for user lookups)
form = CommitFestFilterForm(request.GET)
# Get user profile for timestamp preferences
@@ -1649,10 +1655,15 @@ def cfbot_ingest(message):
failing = branch_status["status"] in ("failed", "timeout") or needs_rebase
finished = branch_status["status"] == "finished"
- if "task_status" in message and message["task_status"]["status"] in (
- "ABORTED",
- "ERRORED",
- "FAILED",
+ if (
+ "task_status" in message
+ and message["task_status"]["status"]
+ in (
+ "ABORTED",
+ "ERRORED",
+ "FAILED",
+ )
+ and message["task_status"]["task_name"] != "FormattingCheck"
):
failing = True
@@ -1694,3 +1705,35 @@ def thread_notify(request):
refresh_single_thread(t)
return HttpResponse(status=200)
+
+
+@login_required
+def cfbot_requeue(request, patchid):
+ """Trigger a requeue of a patch in the cfbot."""
+ if request.method != "POST":
+ return HttpResponseForbidden(b"Invalid method")
+
+ patch = get_object_or_404(Patch, pk=patchid)
+ cf = patch.current_commitfest()
+
+ # Make API call to cfbot
+ import requests
+
+ payload = {
+ "commitfest_id": cf.id,
+ "submission_id": patchid,
+ "shared_secret": settings.CFBOT_SECRET,
+ }
+
+ try:
+ response = requests.post(
+ f"{settings.CFBOT_API_URL}/requeue-patch", json=payload, timeout=10
+ )
+ if response.status_code == 200:
+ messages.success(request, "Patch requeued successfully")
+ else:
+ messages.error(request, f"Failed to requeue patch: {response.status_code}")
+ except requests.exceptions.RequestException as e:
+ messages.error(request, f"Failed to requeue patch: {e}")
+
+ return HttpResponseRedirect(f"/patch/{patchid}/")
diff --git a/pgcommitfest/local_settings_example.py b/pgcommitfest/local_settings_example.py
index b4ad8016..392277b5 100644
--- a/pgcommitfest/local_settings_example.py
+++ b/pgcommitfest/local_settings_example.py
@@ -32,6 +32,7 @@
)
CFBOT_SECRET = "INSECURE"
+CFBOT_API_URL = "http://localhost:5000/api"
# There are already commitfests in the default dummy database data.
# Automatically creating new ones would cause the ones that are visible on the
diff --git a/pgcommitfest/settings.py b/pgcommitfest/settings.py
index 30e5d1cf..e48cf09e 100644
--- a/pgcommitfest/settings.py
+++ b/pgcommitfest/settings.py
@@ -30,6 +30,9 @@
# system time zone.
TIME_ZONE = "GMT"
+# Our code currently compares naive datetimes
+USE_TZ = False
+
# Language code for this installation. All choices can be found here:
# http://www.i18nguy.com/unicode/language-identifiers.html
LANGUAGE_CODE = "en-us"
@@ -40,10 +43,6 @@
# to load the internationalization machinery.
USE_I18N = False
-# If you set this to False, Django will not format dates, numbers and
-# calendars according to the current locale
-USE_L10N = False
-
# Absolute filesystem path to the directory that will hold user-uploaded files.
# Example: "/home/media/media.lawrence.com/media/"
MEDIA_ROOT = ""
@@ -167,6 +166,8 @@
AUTO_CREATE_COMMITFESTS = True
+CFBOT_API_URL = "https://cfbot.cputube.org/api"
+
# Load local settings overrides
try:
from .local_settings import * # noqa: F403
diff --git a/pgcommitfest/test_settings.py b/pgcommitfest/test_settings.py
new file mode 100644
index 00000000..7fd6341f
--- /dev/null
+++ b/pgcommitfest/test_settings.py
@@ -0,0 +1,7 @@
+"""Test settings for pgcommitfest."""
+
+from pgcommitfest.settings import * # noqa: F403
+
+# Disable automatic creation of commitfests during tests
+# Tests should explicitly create the commitfests they need
+AUTO_CREATE_COMMITFESTS = False
diff --git a/pgcommitfest/urls.py b/pgcommitfest/urls.py
index 7165fffc..95c988e4 100644
--- a/pgcommitfest/urls.py
+++ b/pgcommitfest/urls.py
@@ -36,6 +36,7 @@
re_path(r"^patch/(\d+)/committer/(become|remove)/$", views.committer),
re_path(r"^patch/(\d+)/(un)?subscribe/$", views.subscribe),
re_path(r"^patch/(\d+)/(comment|review)/", views.comment),
+ re_path(r"^patch/(\d+)/cfbot_requeue/$", views.cfbot_requeue),
re_path(r"^(\d+)/send_email/$", views.send_email),
re_path(r"^patch/(\d+)/send_email/$", views.send_patch_email),
re_path(r"^(\d+)/reports/authorstats/$", reports.authorstats),
diff --git a/pyproject.toml b/pyproject.toml
index 3ef0d3bb..411d4075 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
[project]
-name = "pgocmmitfest"
+name = "pgcommitfest"
description = "Commitfest app for the PostgreSQL community"
dynamic = ["version"]
readme = "README.md"
@@ -18,6 +18,8 @@ dev = [
"pycodestyle",
"ruff",
"djhtml",
+ "pytest",
+ "pytest-django",
]
[tool.setuptools.packages.find]
@@ -47,3 +49,13 @@ section-order = [
[tool.ruff.lint.isort.sections]
# Group all Django imports into a separate section.
django = ["django"]
+
+[tool.pytest.ini_options]
+DJANGO_SETTINGS_MODULE = "pgcommitfest.test_settings"
+python_files = ["tests.py", "test_*.py", "*_tests.py"]
+testpaths = ["pgcommitfest"]
+addopts = [
+ "--reuse-db",
+ "--strict-markers",
+ "-vv",
+]