Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
116 changes: 115 additions & 1 deletion src/lando/api/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import json
from collections import defaultdict
from datetime import datetime
from functools import wraps
from typing import Callable

Expand All @@ -9,15 +11,29 @@
from django.views import View
from django.views.decorators.csrf import csrf_exempt

from lando.main.models import CommitMap
from lando.main.models import (
CommitMap,
JobStatus,
LandingJob,
Repo,
Revision,
add_revisions_to_job,
)
from lando.main.models.revision import DiffWarning, DiffWarningStatus
from lando.main.scm import (
SCM_TYPE_GIT,
SCM_TYPE_HG,
)
from lando.utils.github import GitHubAPIClient, PullRequest
from lando.utils.phabricator import get_phabricator_client


class APIView(View):
"""A base class for API views."""

pass


def phabricator_api_key_required(func: callable) -> Callable:
"""A simple wrapper that checks for a valid Phabricator API token."""

Expand Down Expand Up @@ -148,3 +164,101 @@ class hg2gitCommitMapView(CommitMapBaseView):
"""Return corresponding CommitMap given an hg hash."""

scm = SCM_TYPE_HG


# TODO: move all these to `lando.api.views.pull_requests`.
class PullRequestAPIView(APIView):
"""Handle pull requests in the API."""

def get(self, request: WSGIRequest, repo_name: str, number: int) -> JsonResponse:
"""Return a serialized JSON representation of a pull request."""
target_repo = Repo.objects.get(name=repo_name)
client = GitHubAPIClient(target_repo)
pull_request = PullRequest(client.get_pull_request(number), target_repo)
patch = client.get_patch(number)
diff = client.get_diff(number)
return JsonResponse({"diff": diff, "patch": patch}, status=200)
return JsonResponse(pull_request.serialize(), status=200)


class LandingJobPullRequestAPIView(View):
"""Handle pull request landing jobs in the API."""

def get(
self, request: WSGIRequest, repo_name: int, pull_number: int
) -> JsonResponse:
"""Return list of landing job ids by status."""

target_repo = Repo.objects.get(name=repo_name)
client = GitHubAPIClient(target_repo)
pull_request = PullRequest(client.get_pull_request(pull_number), target_repo)
landing_jobs = defaultdict(list)
for landing_job in pull_request.landing_jobs:
landing_jobs[landing_job.status].append(landing_job.id)

if len(landing_jobs[JobStatus.LANDED]):
status = "landed"
elif len(landing_jobs[JobStatus.CREATED]):
status = "created"
elif len(landing_jobs[JobStatus.SUBMITTED]):
status = "submitted"
elif len(landing_jobs[JobStatus.IN_PROGRESS]):
status = "in progress"
elif len(landing_jobs[JobStatus.FAILED]):
status = "failed"
else:
status = "unknown"
return JsonResponse({"status": status}, status=200)

def post(
self, request: WSGIRequest, repo_name: int, pull_number: int
) -> JsonResponse:
"""Create a new landing job for a pull request."""

class Form(forms.Form):
"""Simple form to get clean some fields."""

head_sha = forms.CharField()
# base_ref = forms.CharField()

# Create a new landing job for a GitHub pull request.
# To do this, verify that the given hash matches the most recent hash
# in the pull request. So we first refetch the pull request.

target_repo = Repo.objects.get(name=repo_name)
client = GitHubAPIClient(target_repo)
ldap_username = request.user.email
pull_request = PullRequest(client.get_pull_request(pull_number), target_repo)
form = Form(json.loads(request.body))

if not form.is_valid():
return JsonResponse(form.errors, 400)

# TODO, use these for verification.
# target_repo = form.cleaned_data["target_repo"]
# base_ref = form.cleaned_data["base_ref"]

# TODO: validate that the target repo and base_ref match what is in the PR.
# For now, we will just fetch the patch and apply it as-is.

# TODO: this does not work with binary data, must use patch instead.
diff = client.get_diff(pull_number)

job = LandingJob(target_repo=target_repo, requester_email=ldap_username)
job.save()

revision = Revision.objects.create(pull_number=pull_request.number)
patch_data = {
# These should be parsed from the patch, but for now, use logged in user.
"author_name": "Author Name",
"author_email": "Author Email <email@example.org>",
"commit_message": pull_request.title,
"timestamp": int(datetime.now().timestamp()),
}
revision.set_patch(diff, patch_data)
revision.save()
add_revisions_to_job([revision], job)
job.status = JobStatus.SUBMITTED
job.save()

return JsonResponse({"id": job.id}, status=201)
18 changes: 18 additions & 0 deletions src/lando/main/migrations/0032_revision_pull_number.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-10-09 18:55

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("main", "0031_alter_landingjob_requester_email"),
]

operations = [
migrations.AddField(
model_name="revision",
name="pull_number",
field=models.IntegerField(blank=True, null=True),
),
]
5 changes: 5 additions & 0 deletions src/lando/main/models/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,11 @@ def _github_repo_url(self) -> str | None:
if self.is_github:
return self.url.removesuffix(".git")

@property
def _github_repo_org(self) -> str | None:
if self.is_github:
return self._github_repo_url.split("/")[-2]

@property
def git_repo_name(self) -> str:
"""Provide the bare name of the Git repo."""
Expand Down
3 changes: 3 additions & 0 deletions src/lando/main/models/revision.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ class Revision(BaseModel):
# does not track all diffs.
diff_id = models.IntegerField(blank=True, null=True)

# GitHub pull request number, if this is a pull request.
pull_number = models.IntegerField(blank=True, null=True)

# The actual patch with Mercurial metadata format.
patch = models.TextField(blank=True, default="")

Expand Down
34 changes: 4 additions & 30 deletions src/lando/main/scm/git.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import asyncio
import io
import logging
import os
Expand All @@ -11,8 +10,6 @@
from pathlib import Path
from typing import Any

from django.conf import settings
from simple_github import AppAuth, AppInstallationAuth
from typing_extensions import override

from lando.main.scm.commit import CommitData
Expand Down Expand Up @@ -112,6 +109,8 @@ def push(
tags: list[str] | None = None,
):
"""Push local code to the remote repository."""
from lando.utils.github import GitHubAPI

push_command = ["push"]

if force_push:
Expand All @@ -134,7 +133,8 @@ def push(
repo = match["repo"]
repo_name = repo.removesuffix(".git")

token = self._get_github_token(owner, repo_name)
token = GitHubAPI._get_token(owner, repo_name)

if token:
push_path = f"https://git:{token}@github.com/{owner}/{repo}"

Expand All @@ -152,32 +152,6 @@ def push(

self._git_run(*push_command, cwd=self.path)

@staticmethod
def _get_github_token(repo_owner: str, repo_name: str) -> str | None:
"""Obtain a fresh GitHub token to push to the specified repo.

This relies on GITHUB_APP_ID and GITHUB_APP_PRIVKEY to be set in the
settings. Returns None if those are missing.

The app with ID GITHUB_APP_ID needs to be enabled for the target repo.

"""
app_id = settings.GITHUB_APP_ID
private_key = settings.GITHUB_APP_PRIVKEY

if not app_id or not private_key:
logger.warning(
f"Missing GITHUB_APP_ID or GITHUB_APP_PRIVKEY to authenticate against GitHub repo {repo_owner}/{repo_name}",
)
return None

app_auth = AppAuth(
app_id,
private_key,
)
session = AppInstallationAuth(app_auth, repo_owner, repositories=[repo_name])
return asyncio.run(session.get_token())

def last_commit_for_path(self, path: str) -> str:
"""Find last commit to touch a path."""
command = ["log", "--max-count=1", "--format=%H", "--", path]
Expand Down
19 changes: 15 additions & 4 deletions src/lando/main/tests/test_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from collections.abc import Callable
from pathlib import Path
from textwrap import dedent
from unittest import mock
from unittest.mock import MagicMock

import pytest
Expand Down Expand Up @@ -668,17 +669,27 @@ def test_GitSCM_push(
)


def test_GitSCM_push_get_github_token(git_repo: Path):
@pytest.fixture
def mock_github_api_get_token(monkeypatch: pytest.MonkeyPatch):
mock_get_token = MagicMock()
mock_get_token.side_effect = ["ghs_yolo"]

monkeypatch.setattr("lando.utils.github.GitHubAPI._get_token", mock_get_token)

return mock_get_token


def test_GitSCM_push_get_github_token(
git_repo: Path, mock_github_api_get_token: mock.Mock
):
scm = GitSCM(str(git_repo))
scm._git_run = MagicMock()
scm._get_github_token = MagicMock()
scm._get_github_token.side_effect = ["ghs_yolo"]

scm.push("https://github.com/some/repo")

assert scm._git_run.call_count == 1, "_git_run wasn't called when pushing"
assert (
scm._get_github_token.call_count == 1
mock_github_api_get_token.call_count == 1
), "_get_github_token wasn't called when pushing to a github-like URL"
assert (
"git:ghs_yolo@github.com" in scm._git_run.call_args[0][1]
Expand Down
66 changes: 66 additions & 0 deletions src/lando/static_src/legacy/js/components/Stack.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,71 @@ $.fn.stack = function() {
$('.edit-assessment-close').on("click", function () {
$('.uplift-assessment-edit-modal').removeClass("is-active");
});


var landing_button = $('button.post-landing-job');
var pull_number = landing_button.data("pull-number");
var head_sha = landing_button.data("head-sha");
var repo_name = landing_button.data("repo-name");
var csrf_token = landing_button.data("csrf-token");

fetch(`/api/pulls/${repo_name}/${pull_number}/landing_jobs`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRFToken': csrf_token
},
}).then(async response => {
if (response.status == 200) {
var result = await response.json();
if (result.status == "landed") {
landing_button.prop("disabled", true);
landing_button.removeClass("is-loading").addClass("is-danger");
landing_button.html("Pull request landed");
} else if (["created", "submitted", "in progress"].includes(result.status)) {
landing_button.prop("disabled", true);
landing_button.removeClass("is-loading");
// TODO: allow cancelling job in this case.
landing_button.html("Landing job submitted");
} else {
landing_button.prop("disabled", false);
landing_button.removeClass("is-loading").addClass("is-success");;
landing_button.html("Request landing");
}
} else {
// TODO: handle this case.
}
});


landing_button.on('click', function(e) {
landing_button.addClass("is-loading");
fetch(`/api/pulls/${repo_name}/${pull_number}/landing_jobs`, {
method: 'POST',
body: JSON.stringify({"head_sha": head_sha}),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRFToken': csrf_token
},
}).then(response => {
if (response.status == 201) {
window.location.reload();
} else if (response.status == 400) {
landing_button.prop("disabled", true);
landing_button.removeClass("is-danger").removeClass("is-loading").addClass("is-warning");
landing_button.html("Could not create landing job");
} else {
landing_button.prop("disabled", true);
landing_button.removeClass("is-danger").removeClass("is-loading").addClass("is-warning");
landing_button.html("An unknown error occurred");
}
});
});




});
};
Loading