Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
96 changes: 83 additions & 13 deletions 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,7 +11,14 @@
from django.views import View
from django.views.decorators.csrf import csrf_exempt

from lando.main.models import CommitMap, Repo
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,
Expand Down Expand Up @@ -157,20 +166,81 @@ class hg2gitCommitMapView(CommitMapBaseView):
scm = SCM_TYPE_HG


class PullRequestAPIView(APIView):
"""Handle pull requests in the API."""
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."""

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))
return JsonResponse(pull_request.serialize(), status=200)
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()

class LandingJobAPIView(View):
"""Handle landing jobs in the API."""
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))

def post(self, request: WSGIRequest, *args, **kwargs):
"""Placeholder for creating new landing jobs."""
pass
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),
),
]
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
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");
}
});
});
};
});
};
30 changes: 23 additions & 7 deletions src/lando/ui/jinja2/stack/pull_request.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,48 @@

{% block main %}
<main class="StackPage container fullhd">
<h1>Pull request {{ pull_request.number }}</h1>
<h1><a href="{{ pull_request.html_url }}">{{ pull_request.title }}</a></h1>
<div class="StackPage-stack">
<table>
<tr>
<th>Actions</th>
<td>
<button
class="button is-loading post-landing-job"
disabled
data-pull-number="{{ pull_request.number }}"
data-head-sha="{{ pull_request.head_sha }}"
data-repo-name="{{ target_repo.name }}"
data-csrf-token="{{ csrf_token }}">
Loading</button>
</td>
</tr>
<tr>
<th>Pull request</th>
<td>{{ pull_request.number }}</td>
</tr>
<tr>
<th>Target Lando Repo</th>
<td>{{ target_repo }}</td>
</tr>
<tr>
<th>GitHub repo</th>
<td>{{ pull_request.head_repo_git_url }}</td>
<td>{{ target_repo }} ({{ pull_request.head_repo_git_url }})</td>
</tr>
<tr>
<th>Author</th>
<td>{{ pull_request.user_login }}</td>
</tr>
<th>Working branch</th>
<td>{{ pull_request.head_ref }} ({{ pull_request.head_sha }})</td>
</tr>
<tr>
<th>Target branch</th>
<td>{{ pull_request.base_ref }} ({{ pull_request.base_sha }})</td>
</tr>
<tr>
<th>State</th>
<td>{{ pull_request.state}}</td>
</tr>
<tr>
<th>Title</th>
<td><a href="{{ pull_request.html_url }}">{{ pull_request.title }}</a></td>
<td>{{ pull_request.title }}</td>
</tr>
<tr>
<th>Description</th>
Expand Down
2 changes: 1 addition & 1 deletion src/lando/ui/pull_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def get(
"""Handle the GET request for the pull request view."""
target_repo = Repo.objects.get(name=repo_name)
client = GitHubAPIClient(target_repo)
pull_request = PullRequest(client.get_pull_request(number))
pull_request = PullRequest(client.get_pull_request(number), target_repo)

context = {
"target_repo": target_repo,
Expand Down
8 changes: 4 additions & 4 deletions src/lando/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@

from lando.api.legacy.api import landing_jobs
from lando.api.views import (
LandingJobPullRequestAPIView,
LegacyDiffWarningView,
PullRequestAPIView,
git2hgCommitMapView,
hg2gitCommitMapView,
)
Expand Down Expand Up @@ -88,9 +88,9 @@

urlpatterns += [
path(
"api/pulls/<str:repo_name>/<int:number>",
PullRequestAPIView.as_view(),
name="api-pull-request",
"api/pulls/<str:repo_name>/<int:pull_number>/landing_jobs",
LandingJobPullRequestAPIView.as_view(),
name="api-landing-job-pull-request",
),
]

Expand Down
58 changes: 50 additions & 8 deletions src/lando/utils/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def get(self, path: str, *args, **kwargs) -> dict:

def post(self, path: str, *args, **kwargs) -> dict:
"""Send a POST request to the GitHub API with given args and kwargs."""
url = f"self.GITHUB_BASE_URL/{path}"
url = f"{self.GITHUB_BASE_URL}/{path}"
return self.session.post(url, *args, **kwargs)


Expand All @@ -80,6 +80,16 @@ def __init__(self, repo: Repo):

def _get(self, path: str, *args, **kwargs) -> dict:
result = self.client.get(path, *args, **kwargs)
content_type = result.headers["content-type"]
if content_type == "application/json; charset=utf-8":
return result.json()
elif content_type == "application/vnd.github.patch; charset=utf-8":
return result.text
elif content_type == "application/vnd.github.diff; charset=utf-8":
return result.text

def _post(self, path: str, *args, **kwargs):
result = self.client.post(path, *args, **kwargs)
return result.json()

def list_pull_requests(self) -> list:
Expand All @@ -90,8 +100,38 @@ def get_pull_request(self, pull_number: int) -> dict:
"""Get a specific pull request from the repo."""
return self._get(f"{self.repo_base_url}/pulls/{pull_number}")

def get_diff(self, url: str) -> str:
pass
def get_diff(self, pull_number: int) -> str:
"""Fetch a diff, given a pull request number."""
return self._get(
f"{self.repo_base_url}/pulls/{pull_number}",
headers={"Accept": "application/vnd.github.diff"},
)

def get_patch(self, pull_number: int) -> str:
"""Fetch a patch, given a pull request number."""
return self._get(
f"{self.repo_base_url}/pulls/{pull_number}",
headers={"Accept": "application/vnd.github.patch"},
)

def open_pull_request(self, pull_number: int) -> dict:
"""Open the given pull request."""
return self._post(
f"{self.repo_base_url}/pulls/{pull_number}", json={"state": "open"}
)

def close_pull_request(self, pull_number: int) -> dict:
"""Close the given pull request."""
return self._post(
f"{self.repo_base_url}/pulls/{pull_number}", json={"state": "closed"}
)

def add_comment_to_pull_request(self, pull_number: int, comment: str) -> dict:
"""Add a comment to the given pull request."""
return self._post(
f"{self.repo_base_url}/issues/{pull_number}/comments",
json={"body": comment},
)
Comment thread
shtrom marked this conversation as resolved.


class PullRequest:
Expand All @@ -100,10 +140,14 @@ class PullRequest:
def __repr__(self) -> str:
return f"Pull request #{self.number} ({self.head_repo_git_url})"

def __init__(self, data: dict):
def __init__(self, data: dict, repo: Repo):
self.repo = repo
self.url = data["url"]
self.base_ref = data["base"]["ref"] # "source" branch name
self.base_sha = data["base"]["sha"] # "source" branch sha
self.base_ref = data["base"]["ref"] # "target" branch name
self.base_sha = data["base"]["sha"] # "target" branch sha
self.head_ref = data["head"]["ref"] # "working" branch name
self.head_sha = data["head"]["sha"] # "working" branch sha

self.base_user_login = data["base"]["user"]["login"]
self.base_user_id = data["base"]["user"]["id"]
self.created_at = data["created_at"]
Expand All @@ -117,8 +161,6 @@ def __init__(self, data: dict):
self.comments_url = data["comments_url"]
self.commits_url = data["commits_url"]

self.head_ref = data["head"]["ref"] # "destination" branch name
self.head_sha = data["head"]["sha"]
self.head_repo_git_url = data["head"]["repo"][
"git_url"
] # e.g., git://github.com/mozilla-conduit/test-repo.git
Expand Down