diff --git a/src/shared/github.py b/src/shared/github.py
index 89ba433f5..ef71bd448 100644
--- a/src/shared/github.py
+++ b/src/shared/github.py
@@ -28,6 +28,7 @@ def create_gh_issue(
cached_suggestion: CachedSuggestions,
tracker_issue_uri: str,
comment: str | None = None,
+ publisher=None,
# FIXME(@fricklerhandwerk): [tag:todo-github-connection] Make an application-level "GitHub connection" object instead.
# Instantiating the connection at definition time makes mocking it away for tests rather cumbersome.
# Ideally we'd have a generic mock that would abstract away regular book keeping such as app authentication, and tests would override only relevant behavior.
@@ -150,8 +151,9 @@ def additional_comment() -> str:
cached_suggestion.payload["pk"],
)
+ publisher_str = f" Published by @{publisher.username}" if publisher else ""
body = f"""\
-- [{cached_suggestion.payload["cve_id"]}](https://nvd.nist.gov/vuln/detail/{quote(cached_suggestion.payload["cve_id"])})
+- [{cached_suggestion.payload["cve_id"]}](https://nvd.nist.gov/vuln/detail/{quote(cached_suggestion.payload["cve_id"])}){publisher_str}
- [Nixpkgs security tracker issue]({tracker_issue_uri})
{maintainers()}
## Description
diff --git a/src/shared/migrations/0071_nixpkgsissue_publisher.py b/src/shared/migrations/0071_nixpkgsissue_publisher.py
new file mode 100644
index 000000000..6b4fe5e81
--- /dev/null
+++ b/src/shared/migrations/0071_nixpkgsissue_publisher.py
@@ -0,0 +1,21 @@
+# Generated by Django 5.2.9 on 2026-03-12 17:50
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('shared', '0070_cvederivationclusterproposal_rejection_reason'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='nixpkgsissue',
+ name='publisher',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='published_issues', to=settings.AUTH_USER_MODEL),
+ ),
+ ]
diff --git a/src/shared/models/issue.py b/src/shared/models/issue.py
index 56670e234..d93a3fdfe 100644
--- a/src/shared/models/issue.py
+++ b/src/shared/models/issue.py
@@ -4,6 +4,7 @@
from django.core.exceptions import ValidationError
from django.db import IntegrityError, models
+from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
@@ -38,6 +39,13 @@ class NixpkgsIssue(models.Model):
suggestion = models.OneToOneField(
CVEDerivationClusterProposal, on_delete=models.PROTECT
)
+ publisher = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="published_issues",
+ )
status = models.CharField(
max_length=text_length(IssueStatus),
@@ -61,7 +69,7 @@ def status_string(self) -> str:
@classmethod
def create_nixpkgs_issue(
- cls, suggestion: CVEDerivationClusterProposal
+ cls, suggestion: CVEDerivationClusterProposal, publisher=None
) -> "NixpkgsIssue":
"""
Create a NixpkgsIssue from a suggestion and save it in the database. Note
@@ -75,6 +83,7 @@ def create_nixpkgs_issue(
# end.
status=IssueStatus.AFFECTED,
suggestion=suggestion,
+ publisher=publisher,
)
issue.save()
return issue
diff --git a/src/webview/suggestions/views/status.py b/src/webview/suggestions/views/status.py
index 1e894071e..4ce34b038 100644
--- a/src/webview/suggestions/views/status.py
+++ b/src/webview/suggestions/views/status.py
@@ -76,7 +76,7 @@ def post(self, request: HttpRequest, suggestion_id: int) -> HttpResponse:
elif new_status == "published":
try:
with transaction.atomic():
- tracker_issue = NixpkgsIssue.create_nixpkgs_issue(suggestion)
+ tracker_issue = NixpkgsIssue.create_nixpkgs_issue(suggestion, publisher=request.user)
tracker_issue_link = request.build_absolute_uri(
reverse("webview:issue_detail", args=[tracker_issue.code])
)
@@ -84,6 +84,7 @@ def post(self, request: HttpRequest, suggestion_id: int) -> HttpResponse:
suggestion_context.suggestion.cached,
tracker_issue_link,
new_comment,
+ publisher=request.user,
).html_url
NixpkgsEvent.objects.create(
issue=tracker_issue,
diff --git a/src/webview/templates/components/issue.html b/src/webview/templates/components/issue.html
index 3f746de55..4ccdb9277 100644
--- a/src/webview/templates/components/issue.html
+++ b/src/webview/templates/components/issue.html
@@ -10,7 +10,7 @@
{% if github_issue %}
GitHub issue
{% endif %}
- published on {{ issue.created }}
+ published {% if issue.publisher %}by @{{ issue.publisher.username }} {% endif %}on {{ issue.created }}
diff --git a/src/webview/tests/test_issues.py b/src/webview/tests/test_issues.py
index 38c878b39..2b442a621 100644
--- a/src/webview/tests/test_issues.py
+++ b/src/webview/tests/test_issues.py
@@ -71,6 +71,9 @@ def test_publish_gh_issue_empty_title(
link = as_staff.get_by_role("link", name="View")
expect(link).to_be_visible()
mock.assert_called()
+
+ # Check publisher name exists in kwargs
+ assert mock.call_args[1]["publisher"].username == "staff"
if no_js:
error = as_staff.locator("#messages")
@@ -89,6 +92,7 @@ def test_publish_gh_issue_empty_title(
issue_link = suggestion.locator("..").get_by_role("link", name="GitHub issue")
expect(issue_link).to_be_visible()
+ expect(suggestion.locator("..").filter(has_text="by @staff")).to_be_visible()
# FIXME(@fricklerhandwerk): Instrument the GitHub mock to produce a controlled link and check for that in the UI.
# This would assert we're actually displaying the right URL.
expect(issue_link).not_to_have_attribute("href", "")
@@ -177,3 +181,5 @@ def mock_get_maintainer_username(
assert f"@{maintainer_handle}" not in issue_body
else:
assert f"@{maintainer_handle}" in issue_body
+
+ assert "Published by @staff" in issue_body