Skip to content
Open
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
37 changes: 37 additions & 0 deletions src/lando/headless_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from io import StringIO
from typing import Annotated, Literal

from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.handlers.wsgi import WSGIRequest
from django.db import transaction
Expand All @@ -16,6 +17,7 @@
from ninja.responses import codes_4xx
from ninja.security import HttpBearer
from pydantic import Field, TypeAdapter
from pydantic.types import StringConstraints

from lando.headless_api.models.automation_job import (
AutomationAction,
Expand Down Expand Up @@ -324,13 +326,48 @@ def process(
raise NotImplementedError()


class MergeRemoteAction(Schema):
"""Merge changes from a remote repository"""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
"""Merge changes from a remote repository"""
"""Merge changes from a remote repository."""


action: Literal["merge-remote"]
commit_message: str
repo: str
commit: Annotated[str, StringConstraints(pattern="[0-9a-fA-F]{40}")]
allow_unrelated: bool = False

def process(
self, job: AutomationJob, repo: Repo, scm: AbstractSCM, index: int
) -> bool:
if self.repo not in settings.ALLOWED_MERGE_REMOTE_REPOS:
raise AutomationActionException(
message=f"Merges from {repo} not allowed",
job_action=JobAction.FAIL,
is_fatal=True,
)
try:
scm.merge_remote(
commit_message=self.commit_message,
remote=self.repo,
commit=self.commit,
allow_unrelated=self.allow_unrelated,
)
except Exception as exc:
message = f"Aborting, could not `merge-remote`, action #{index}.\n{exc}"
raise AutomationActionException(
message=message, job_action=JobAction.FAIL, is_fatal=True
) from exc

return True


Action = (
AddCommitAction
| AddCommitBase64Action
| CreateCommitAction
| MergeOntoAction
| AddBranchAction
| TagAction
| MergeRemoteAction
)

ActionAdapter = TypeAdapter(Action)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 5.2.5 on 2025-09-01 15:03

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("headless_api", "0008_alter_automationjob_requester_email"),
]

operations = [
migrations.AlterField(
model_name="automationaction",
name="action_type",
field=models.CharField(
choices=[
("add-commit", "Add commit"),
("add-commit-base64", "Add base64 commit"),
("create-commit", "Create commit"),
("tag", "Tag"),
("merge-onto", "Merge onto"),
("merge-remote", "Merge commit from remote repository"),
]
),
),
]
1 change: 1 addition & 0 deletions src/lando/headless_api/models/automation_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ class ActionTypeChoices(models.TextChoices):
CREATE_COMMIT = "create-commit", gettext_lazy("Create commit")
TAG = "tag", gettext_lazy("Tag")
MERGE_ONTO = "merge-onto", gettext_lazy("Merge onto")
MERGE_REMOTE = "merge-remote", gettext_lazy("Merge commit from remote repository")


class AutomationAction(BaseModel):
Expand Down
43 changes: 43 additions & 0 deletions src/lando/headless_api/tests/test_automation_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import Callable

import pytest
from django.conf import settings
from django.contrib.auth.hashers import check_password

from lando.api.legacy.workers.automation_worker import AutomationWorker
Expand Down Expand Up @@ -1928,3 +1929,45 @@ def test_automation_job_processing(automation_job):
assert (
job_from_db.duration_seconds > 0
), "`processing` should set and save the job duration."


@pytest.mark.django_db
def test_automation_job_merge_remote_success_git(
repo_mc,
treestatusdouble,
git_automation_worker,
monkeypatch,
request,
automation_job,
):
repo = repo_mc(SCM_TYPE_GIT)
scm = repo.scm
scm.push = mock.MagicMock()

settings.ALLOWED_MERGE_REMOTE_REPOS = [repo.path]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we should add a test that asserts the merge is rejected on a repo name that isn't in the ALLOWED_MERGE_REMOTE_REPOS setting. If someone removed that check it wouldn't show up in the tests at the moment. :)


# Create a repo with diverging history
main_commit, main_file, feature_commit, feature_file = (
_create_split_branches_for_merge(request, scm, repo.system_path)
)

job, _actions = automation_job(
actions=[
{
"action": "merge-remote",
"commit_message": "No bug: Remote merge test",
"repo": repo.path,
"commit": feature_commit,
}
],
status=JobStatus.SUBMITTED,
requester_email="test@example.com",
target_repo=repo,
)

git_automation_worker.worker_instance.applicable_repos.add(repo)

assert git_automation_worker.run_job(job)
assert job.status == JobStatus.LANDED, f"Job unexpectedly failed: {job.error}"
assert scm.push.called
assert len(job.landed_commit_id) == 40
9 changes: 9 additions & 0 deletions src/lando/main/scm/abstract_scm.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,3 +292,12 @@ def tag(self, name: str, target: str | None):

If `target` is `None`, use the currently checked out commit.
"""

@abstractmethod
def merge_remote(
self, commit_message: str, remote: str, commit: str, allow_unrelated: bool
) -> str:
"""Merge changes from the referenced remote and commit.

Return the SHA of the newly created merge commit.
"""
11 changes: 11 additions & 0 deletions src/lando/main/scm/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,3 +716,14 @@ def tag(self, name: str, target: str | None):

# If the tag did not already exist, bubble up the exception.
raise exc

def merge_remote(
self, commit_message: str, remote: str, commit: str, allow_unrelated: bool
) -> str:
self._git_run("fetch", remote, commit, cwd=self.path)
merge_args = ["--no-ff", "-m", commit_message]
if allow_unrelated:
merge_args.append("--allow-unrelated-histories")
self._git_run("merge", *merge_args, commit, cwd=self.path)

return self.head_ref()
5 changes: 5 additions & 0 deletions src/lando/main/scm/hg.py
Original file line number Diff line number Diff line change
Expand Up @@ -813,3 +813,8 @@ def tag(self, name: str, target: str | None):
tag_command.append(target)

self.run_hg(tag_command)

def merge_remote(
self, commit_message: str, remote: str, commit: str, allow_unrelated: bool
) -> str:
raise NotImplementedError("`merge_remote` not implemented for hg.")
Loading