Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
af52580
github: add GitHubAPI, GitHubAPIClient classes (bug 1989637)
zzzeid Sep 19, 2025
456ba02
github: add pull request view, url, template (bug 1989963)
zzzeid Sep 26, 2025
9e76920
github: add pull request endpoint (bug 1991125)
zzzeid Oct 6, 2025
1c63a7a
pull_request: add more functionality to end points and ui (bug 1991125)
zzzeid Oct 14, 2025
dea1b83
github: add write methods (bug 1989960)
zzzeid Oct 15, 2025
5f9c40d
landing_worker: add comment + close pr functionality (bug 1994736)
zzzeid Oct 17, 2025
a54f526
github: add GitHubAPI, GitHubAPIClient classes (bug 1989637) (#536)
zzzeid Oct 22, 2025
f9cc520
github: add pull request view, url, template (bug 1989963) (#553)
zzzeid Oct 22, 2025
46cc0be
github: add pull request endpoint (bug 1991125) (#582)
zzzeid Oct 22, 2025
d5de18f
pull_request: add more functionality to end points and ui (bug 1991125)
zzzeid Oct 14, 2025
dfc7be3
Merge branch 'zeid/bug-1991125-landing-job-backend-frontend' of githu…
zzzeid Oct 22, 2025
1446cb0
code review feedback
zzzeid Oct 22, 2025
5fc249b
code review feedback
zzzeid Oct 24, 2025
c5303a7
clean up
zzzeid Oct 27, 2025
18b2b88
more clean up
zzzeid Oct 27, 2025
f0dc67a
minor change
zzzeid Oct 27, 2025
1652ee6
minor change
zzzeid Oct 27, 2025
850827c
clean up
zzzeid Oct 27, 2025
e22d561
small refactor
zzzeid Oct 27, 2025
e957288
fixes
zzzeid Oct 27, 2025
138326c
small fix
zzzeid Oct 27, 2025
d9651bd
Merge branch 'zeid/bug-1991125-landing-job-backend-frontend' into zei…
zzzeid Oct 27, 2025
0ba490c
Merge branch 'zeid/bug-1989960-modify-pr-api' into zeid/bug-1994736-c…
zzzeid Oct 27, 2025
4daf7dd
fix error
zzzeid Oct 27, 2025
d2cf002
pull_request: add more functionality to end points and ui (bug 199112…
zzzeid Oct 28, 2025
a9a01e9
github: add write methods (bug 1989960) (#611)
zzzeid Oct 28, 2025
30969da
Merge branch 'zeid/bug-1989635-github-pr-pilot' into zeid/bug-1994736…
zzzeid Oct 28, 2025
34074e5
merge fixups
zzzeid Oct 28, 2025
fbd254f
Merge branch 'zeid/bug-1989635-github-pr-pilot' into zeid/bug-1994736…
zzzeid Oct 28, 2025
5b15cc7
merge fixes
zzzeid Oct 28, 2025
7bb963d
Revert "merge fixes"
zzzeid Oct 28, 2025
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
10 changes: 10 additions & 0 deletions src/lando/api/legacy/workers/landing_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
)
from lando.pushlog.pushlog import PushLog, PushLogForRepo
from lando.utils.config import read_lando_config
from lando.utils.github import GitHubAPIClient
from lando.utils.tasks import phab_trigger_repo_update

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -143,6 +144,15 @@ def run_job(self, job: LandingJob) -> bool:

job.transition_status(JobAction.LAND, commit_id=commit_id)

if job.is_pull_request_job:
# TODO: move this to different method, and retry if needed.
Comment thread
zzzeid marked this conversation as resolved.
# NOTE: This may need to happen on the revision-level when stack support is added.
pull_number = job.revisions.first().pull_number
message = f"Pull request closed by commit {commit_id}"
client = GitHubAPIClient(job.target_repo)
Comment thread
zzzeid marked this conversation as resolved.
client.add_comment_to_pull_request(pull_number, message)
client.close_pull_request(pull_number)

mots_path = Path(repo.path) / "mots.yaml"
if mots_path.exists():
logger.info(f"{mots_path} found, setting reviewer data.")
Expand Down
98 changes: 97 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,83 @@ class hg2gitCommitMapView(CommitMapBaseView):
"""Return corresponding CommitMap given an hg hash."""

scm = SCM_TYPE_HG


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

def get(
self, request: WSGIRequest, repo_name: int, pull_number: int
) -> JsonResponse:
"""Return the status of a pull request based on landing job counts."""

target_repo = Repo.objects.get(name=repo_name)
landing_jobs_by_status = defaultdict(list)

revisions = Revision.objects.filter(
landing_jobs__target_repo=target_repo, pull_number=pull_number
)
landing_jobs = LandingJob.objects.filter(
unsorted_revisions__in=revisions
).order_by("-created_at")

for landing_job in landing_jobs:
landing_jobs_by_status[landing_job.status].append(landing_job.id)

status = None
# Return the first encountered status in this list.
for _status in [
JobStatus.LANDED,
JobStatus.CREATED,
JobStatus.SUBMITTED,
JobStatus.IN_PROGRESS,
JobStatus.FAILED,
]:
if landing_jobs_by_status[_status]:
status = str(_status).lower()
break

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()
# TODO: use this for verification later, see bug 1996571.
# base_ref = forms.CharField()

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: this does not work with binary data, must use patch instead.
# See bug 1993047.
diff = client.get_diff(pull_number)
job = LandingJob.objects.create(
target_repo=target_repo, requester_email=ldap_username
)
revision = Revision.objects.create(pull_number=pull_request.number)
patch_data = {
# See bug 1995006 (to actually parse authorship info). Use placeholder for now.
"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),
),
]
4 changes: 4 additions & 0 deletions src/lando/main/models/landing_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ class LandingJob(BaseJob):
Revision, through="RevisionLandingJob", related_name="landing_jobs"
)

def is_pull_request_job(self) -> bool:
"""Return True if all revisions in the landing job have a pull_number set."""
return not self.revisions.filter(pull_number__isnull=True).exists()

@property
def landed_phabricator_revisions(self) -> dict:
"""Return a mapping associating Phabricator revision IDs with the ID of the landed Diff."""
Expand Down
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");
});


// Simple check for time being. If the button exists, assume this is a pull request page.
// This should be cleaned up as part of bug 1995754.
var is_pull_request_page = Boolean($('button.post-landing-job').length);
if (is_pull_request_page) {
var pull_request_button = $('button.post-landing-job');

var pull_number = pull_request_button.data("pull-number");
var head_sha = pull_request_button.data("head-sha");
var repo_name = pull_request_button.data("repo-name");
var csrf_token = pull_request_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") {
pull_request_button.prop("disabled", true);
pull_request_button.removeClass("is-loading").addClass("is-danger");
pull_request_button.html("Pull request landed");
} else if (["created", "submitted", "in progress"].includes(result.status)) {
pull_request_button.prop("disabled", true);
pull_request_button.removeClass("is-loading");
pull_request_button.html("Landing job submitted");
} else {
pull_request_button.prop("disabled", false);
pull_request_button.removeClass("is-loading").addClass("is-success");;
pull_request_button.html("Request landing");
}
} else {
// TODO: handle this case. See bug 1996000.
}
});

pull_request_button.on('click', function(e) {
pull_request_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) {
pull_request_button.prop("disabled", true);
pull_request_button.removeClass("is-danger").removeClass("is-loading").addClass("is-warning");
pull_request_button.html("Could not create landing job");
} else {
pull_request_button.prop("disabled", true);
pull_request_button.removeClass("is-danger").removeClass("is-loading").addClass("is-warning");
pull_request_button.html("An unknown error occurred");
}
});
});
};
});
};
Loading
Loading