Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/admin/code-hosting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,17 @@ GitHub.com, use
write repository contents and create pull requests. If Weblate should fork
private repositories, the token might also need administration access.

Use :ref:`component-vcs_params` to add labels or an assignee to pull requests.
Weblate can also assign the GitHub account linked to the user who started the
push. This dynamic assignee is only added to newly created pull requests, and is
skipped when the push has no acting user or that user has no GitHub account
linked to Weblate. GitHub ignores assignees without access to the repository,
including the acting user, and Weblate reports them as a component alert.
When Weblate pushes to an already open pull request, it adds only the configured
labels and assignee that are missing. Applying labels and assignees requires
permission to write pull requests; failures are reported as component alerts
without failing a push which already succeeded.

If creating a pull request fails with ``404 Not Found``, GitHub can be refusing
API access even when the repository is readable. Weblate checks the repository's
pull request creation policy and, for user tokens, the authenticated account.
Expand Down
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Weblate 2026.11
* Docker deployments can :ref:`trust custom CA certificates <docker-custom-ca>` for outgoing connections, including repository operations over HTTPS.
* :ref:`Automations <automation-workflows>` can be edited visually, with a toggle to the raw YAML.
* Translation services support multiple installations of one service at site, workspace, and project scope, with optional slugs, inherited-installation exclusions, and reversible pause controls. See :ref:`machine-translation-setup`.
* GitHub pull requests can be assigned to a configured user or the acting user's linked GitHub account, and can have configured labels added using :ref:`vcs_params`.

.. rubric:: Improvements

Expand Down
63 changes: 36 additions & 27 deletions docs/snippets/vcs-parameters.rst

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions weblate/templates/bootstrap5/layout/tab-link.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% comment %}Based on crispy original, adds data-bs-target to open tabs from the URL.{% endcomment %}
<li class="nav-item"><a class="nav-link{% if 'active' in link.css_class %} active{% endif %}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WCAG 1.3.1: <li> is not contained in a <ul>, <ol>, or <menu>.

<li> elements must be contained in a <ul>, <ol>, or <menu>.

Details

List items (<li>) only have semantic meaning inside a list container (<ul>, <ol>, or <menu>). Without a list parent, screen readers will not announce 'list with N items' or allow users to skip between items using list navigation shortcuts. Wrap <li> elements in the appropriate list container — <ul> for unordered lists, <ol> for ordered/numbered lists.

href="#{{ link.css_id }}"
data-bs-target="#{{ link.css_id }}"
data-bs-toggle="tab">{{ link.name|capfirst }}{% if tab.errors %}!{% endif %}</a></li>
17 changes: 17 additions & 0 deletions weblate/templates/trans/alert/pullrequestupdatefailure.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% load i18n permissions %}

<p>{% translate "Weblate pushed the changes and created the pull request, but could not apply the configured labels or assignees." %}</p>

<pre>{{ error }}</pre>

<p>
{% translate "Check that the labels and assignees exist and that Weblate can write pull requests, or remove the customization from the version control parameters." %}
</p>

{% perm 'component.edit' component as user_can_edit_component %}
{% if user_can_edit_component %}
<p>
<a class="btn btn-primary"
href="{% url 'settings' path=component.get_url_path %}#vcs">{% translate "Edit component settings" %}</a>
</p>
{% endif %}
32 changes: 31 additions & 1 deletion weblate/trans/alerts/vcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@
get_repository_error_diagnoses,
)
from weblate.vcs.git import GitRepository, SubversionRepository
from weblate.vcs.params import GitForcePush, MergeRequestAutomerge
from weblate.vcs.params import (
CreateMergeRequest,
GitForcePush,
MergeRequestAutomerge,
MergeRequestLabels,
wants_pull_request_customization,
)

if TYPE_CHECKING:
from weblate.auth.models import User
Expand Down Expand Up @@ -477,6 +483,30 @@ def check_component(component: Component) -> bool | dict | None:
return None


@register
class PullRequestUpdateFailure(RepositoryErrorAlert):
"""Pull request labels or assignees could not be applied."""

# Translators: Name of an alert
verbose = gettext_lazy("Could not customize the pull request.")
category = AlertCategory.VCS
link_wide = True
doc_page = "vcs"
doc_anchor = "vcs_params"
repository_permissions = ("vcs.push",)

@staticmethod
def check_component(component: Component) -> bool | dict | None:
params = component.vcs_params
if (
not MergeRequestLabels.supports_vcs(component.vcs)
or not CreateMergeRequest.get_value(params)
or not wants_pull_request_customization(params)
):
return False
return None


@register
class RepositoryOutdated(RepositoryAlert):
# Translators: Name of an alert
Expand Down
18 changes: 17 additions & 1 deletion weblate/trans/models/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
store_task_metadata,
)
from weblate.utils.change_origin import (
acting_user_context,
capture_change_origin,
get_acting_user,
get_change_origin,
Expand Down Expand Up @@ -2276,7 +2277,7 @@
self.translations_progress += 1
progress = 100 * self.translations_progress // self.translations_count
# ruff: ignore[import-outside-top-level]
from weblate.trans.repository_context import repository_task_progress_scope

Check failure on line 2280 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Unsupported operand types for // ("int" and "None")

if (scope := repository_task_progress_scope.get()) is not None:
completed, total = scope
Expand Down Expand Up @@ -2396,7 +2397,7 @@
try:
result = self.translation_set.get(language_id=self.source_language_id)
except ObjectDoesNotExist:
return None

Check failure on line 2400 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible type for lookup 'language_id': (got "int | None", expected "str | int")
result.sync_readonly_check_flag()
self.__dict__["source_translation"] = result
return result
Expand Down Expand Up @@ -2439,7 +2440,7 @@
try:
result = self.translation_set.get(
language_id=self.source_language_id
)

Check failure on line 2443 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible type for lookup 'language_id': (got "int | None", expected "str | int")
except self.translation_set.model.DoesNotExist:
pass
else:
Expand Down Expand Up @@ -2477,7 +2478,7 @@
# Avoid fetching empty list of checks from the database
source.all_checks = []
source.source_updated = True
user = user or self.get_update_user(None)

Check failure on line 2481 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Incompatible types in assignment (expression has type "list[Never]", variable has type "QuerySet[Check, Check]")
change = source.generate_change(
user,
user,
Expand Down Expand Up @@ -3570,7 +3571,8 @@
user = self.get_push_user(request)

# Do actual push
result = self.push_repo(request, user, retry=retry)
with acting_user_context(user):
result = self.push_repo(request, user, retry=retry)
if not result:
return False

Expand Down Expand Up @@ -4552,6 +4554,20 @@
return
self.delete_alert("AutomergeFailure")

def handle_pull_request_update_failure(
self, error: str | RepositoryStructuredError
) -> None:
"""Surface a failed pull request customization."""
if self._state.adding:
return
self.add_alert("PullRequestUpdateFailure", error=error)

def handle_pull_request_update_success(self) -> None:
"""Clear a pull request customization failure."""
if self._state.adding:
return
self.delete_alert("PullRequestUpdateFailure")

def handle_repository_recovery_failure(self, error: Exception) -> None:
"""Surface failed recovery from interrupted repository operations."""
if self._state.adding:
Expand Down Expand Up @@ -4862,7 +4878,7 @@
if item.severity >= AlertSeverity.ERROR
]

def clear_prefetched_alerts(self) -> None:

Check failure on line 4881 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

"Component" has no attribute "_prefetched_objects_cache"
with suppress(AttributeError, KeyError):
self._prefetched_objects_cache.pop("alert_set")

Expand Down Expand Up @@ -5167,7 +5183,7 @@
if self._template_check_done:
return
if self.has_template():
# Avoid parsing if template is invalid

Check failure on line 5186 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Item "None" of "TranslationFormat[Any, Any, Any] | None" has no attribute "check_valid"
try:
self.template_store.check_valid()
except (ValueError, FileParseError) as exc:
Expand Down Expand Up @@ -5238,7 +5254,7 @@
if not self.has_template():
# This creates the translation when necessary
translation = self.source_translation

Check failure on line 5257 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Argument 1 to "exists" has incompatible type "str | None"; expected "int | str | bytes | PathLike[str] | PathLike[bytes]"
if self.is_gettext_po_template() and os.path.exists(
self.get_new_base_filename()
):
Expand Down Expand Up @@ -5309,7 +5325,7 @@
"skipping update due to error in parsing template: %s",
error.__cause__,
)
self.handle_parse_error(error.__cause__, filename=self.template)

Check failure on line 5328 in weblate/trans/models/component.py

View workflow job for this annotation

GitHub Actions / mypy

Exception must be derived from BaseException
self.update_import_alerts()
raise error.__cause__ from error # pylint: disable=raising-non-exception
was_change |= bool(translation.reason)
Expand Down
49 changes: 49 additions & 0 deletions weblate/trans/tests/test_alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -1422,6 +1422,55 @@ def test_automerge_failure_alert_cleared_when_turned_off(self) -> None:
self.component.save(update_fields=["vcs_params"])
self.assertNotIn("AutomergeFailure", self.get_problem_alert_names())

def set_pull_request_customization(self, **vcs_params) -> None:
self.component.vcs = "github"
self.component.vcs_params = vcs_params
self.component.save(update_fields=["vcs", "vcs_params"])

@override_settings(
GITHUB_CREDENTIALS={"api.github.com": {"username": "test", "token": "token"}}
)
def test_pull_request_update_failure_alert(self) -> None:
self.set_pull_request_customization(merge_request_labels="translations")
self.component.handle_pull_request_update_failure(
"GitHub API request failed while customizing a pull request (403): "
"Resource not accessible by integration"
)
alert = self.component.alert_set.get(name="PullRequestUpdateFailure")

rendered = alert.render(self.user)
self.assertIn("Resource not accessible", rendered)
self.assertIn("could not apply the configured labels or assignees", rendered)

self.component.handle_pull_request_update_success()
self.assertFalse(
self.component.alert_set.filter(name="PullRequestUpdateFailure").exists()
)

def assert_pull_request_update_failure_cleared(self, **vcs_params) -> None:
self.set_pull_request_customization(merge_request_assignee="octocat")
self.component.handle_pull_request_update_failure("boom")
self.assertIn("PullRequestUpdateFailure", self.get_problem_alert_names())

self.set_pull_request_customization(**vcs_params)
self.assertNotIn("PullRequestUpdateFailure", self.get_problem_alert_names())

@override_settings(
GITHUB_CREDENTIALS={"api.github.com": {"username": "test", "token": "token"}}
)
def test_pull_request_update_failure_alert_cleared_when_turned_off(self) -> None:
self.assert_pull_request_update_failure_cleared()

@override_settings(
GITHUB_CREDENTIALS={"api.github.com": {"username": "test", "token": "token"}}
)
def test_pull_request_update_failure_alert_cleared_without_merge_request(
self,
) -> None:
self.assert_pull_request_update_failure_cleared(
merge_request_assignee="octocat", create_merge_request=False
)

def test_view(self) -> None:
response = self.client.get(self.component.get_absolute_url())
self.assertContains(response, "Duplicated translation")
Expand Down
23 changes: 23 additions & 0 deletions weblate/trans/tests/test_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -2604,6 +2604,29 @@ def test_failed_push(self) -> None:
self.assertTrue(change.user.is_bot)
self.assertEqual(change.user.full_name, "Background push")

def test_push_exposes_acting_user_to_repository(self) -> None:
user = create_test_user()
request = SimpleNamespace(user=user)
previous_acting_user = get_acting_user()
acting_users = []

def push_repo(*_args, **_kwargs):
acting_users.append(get_acting_user())
return True

with (
patch.object(self.component, "can_push", return_value=True),
patch.object(self.component, "repo_needs_push", return_value=True),
patch.object(self.component, "push_repo", side_effect=push_repo),
):
result = self.component.do_push(
request, force_commit=False, do_update=False
)

self.assertTrue(result)
self.assertEqual(acting_users, [user])
self.assertEqual(get_acting_user(), previous_acting_user)

def test_failed_reset(self) -> None:
# Corrupt Git database so that reset fails
remove_tree(os.path.join(self.component.full_path, ".git", "objects", "pack"))
Expand Down
109 changes: 109 additions & 0 deletions weblate/trans/tests/test_vcs_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from typing import TYPE_CHECKING, cast

from django.conf import settings
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase
from django.test.utils import override_settings
Expand All @@ -18,12 +19,17 @@
from weblate.trans.forms import ComponentCreateForm
from weblate.trans.models import Component
from weblate.trans.tests.test_views import ViewTestCase
from weblate.utils.change_origin import acting_user_context
from weblate.utils.views import get_form_data
from weblate.vcs.git import GithubRepository
from weblate.vcs.params import (
VCS_PARAMS,
CreateMergeRequest,
GitForcePush,
MergeRequestAssignActingUser,
MergeRequestAssignee,
MergeRequestAutomerge,
MergeRequestLabels,
MergeRequestMergeMethod,
get_default_params_for_vcs,
get_params_for_vcs,
Expand All @@ -46,7 +52,10 @@ def test_github_scope(self) -> None:
names,
{
"create_merge_request",
"merge_request_assignee",
"merge_request_assign_acting_user",
"merge_request_automerge",
"merge_request_labels",
"merge_request_merge_method",
},
)
Expand All @@ -60,7 +69,10 @@ def test_defaults(self) -> None:
get_default_params_for_vcs("github"),
{
"create_merge_request": True,
"merge_request_assignee": "",
"merge_request_assign_acting_user": False,
"merge_request_automerge": False,
"merge_request_labels": "",
"merge_request_merge_method": "merge",
},
)
Expand Down Expand Up @@ -123,6 +135,22 @@ def test_boolean_values_are_cleaned_by_field(self) -> None:
MergeRequestAutomerge.get_value({"merge_request_automerge": "false"}),
False,
)
self.assertIs(
MergeRequestAssignActingUser.get_value(
{"merge_request_assign_acting_user": "true"}
),
True,
)

def test_pull_request_text_values_are_cleaned_by_field(self) -> None:
self.assertEqual(
MergeRequestAssignee.get_value({"merge_request_assignee": " user "}),
"user",
)
self.assertEqual(
MergeRequestLabels.get_value({"merge_request_labels": " i18n, docs "}),
"i18n, docs",
)


class ComponentVCSParamsTest(ViewTestCase):
Expand Down Expand Up @@ -154,6 +182,82 @@ def test_unknown_param_rejected(self) -> None:
with self.assertRaises(ValidationError):
self.component.full_clean(exclude=["repo"])

def get_acting_user_login(self, hostname: str) -> str | None:
repository = GithubRepository.__new__(GithubRepository)
repository.component = self.component
with acting_user_context(self.user):
return repository.get_acting_user_github_login(
{"hostname": hostname} # type: ignore[typeddict-item]
)

def test_github_acting_user_login(self) -> None:
self.user.social_auth.create(
provider="github", uid="123", extra_data={"login": "translator"}
)

self.assertEqual(self.get_acting_user_login("api.github.com"), "translator")
self.assertIsNone(self.get_acting_user_login("github.example.com"))

def test_github_acting_user_login_without_acting_user(self) -> None:
self.user.social_auth.create(
provider="github", uid="123", extra_data={"login": "translator"}
)
repository = GithubRepository.__new__(GithubRepository)
repository.component = self.component

with acting_user_context(None):
self.assertIsNone(
repository.get_acting_user_github_login(
{"hostname": "api.github.com"} # type: ignore[typeddict-item]
)
)

@override_settings(
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL="https://github.example.com/api/v3/"
)
def test_github_enterprise_acting_user_matches_host(self) -> None:
self.user.social_auth.create(
provider="github-enterprise-org",
uid="123",
extra_data={"login": "translator"},
)

for hostname, expected in (
("github.example.com", "translator"),
("other.example.com", None),
("api.github.com", None),
):
with self.subTest(hostname=hostname):
self.assertEqual(self.get_acting_user_login(hostname), expected)

@override_settings(
SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL="https://other.example.com/api/v3/",
SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL="https://github.example.com/api/v3/",
)
def test_github_enterprise_acting_user_uses_backend_setting(self) -> None:
self.user.social_auth.create(
provider="github-enterprise-team",
uid="123",
extra_data={"login": "translator"},
)
self.user.social_auth.create(
provider="github-enterprise",
uid="456",
extra_data={"login": "other"},
)

for hostname, expected in (
("github.example.com", "translator"),
("other.example.com", "other"),
):
with self.subTest(hostname=hostname):
self.assertEqual(self.get_acting_user_login(hostname), expected)

def test_github_enterprise_acting_user_without_setting(self) -> None:
with self.settings():
del settings.SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL
self.assertIsNone(self.get_acting_user_login("github.example.com"))

def test_param_not_applicable_to_vcs(self) -> None:
self.component.vcs_params = {"merge_request_automerge": True}
with self.assertRaises(ValidationError) as context:
Expand Down Expand Up @@ -215,6 +319,11 @@ def test_settings_form_round_trip(self) -> None:
self.component.refresh_from_db()
self.assertIs(self.component.vcs_params["git_force_push"], True)

def test_settings_vcs_tab_link(self) -> None:
url = reverse("settings", kwargs={"path": self.component.get_url_path()})
# Alerts link to the version control tab
self.assertContains(self.client.get(url), 'data-bs-target="#vcs"')

def test_settings_form_strips_inapplicable(self) -> None:
url = reverse("settings", kwargs={"path": self.component.get_url_path()})
response = self.client.get(url)
Expand Down
Loading
Loading