use anyhow::{Context, Result, ensure};
use super::GitHub;
use crate::review::PullRequestUrl;
use crate::trust::trusted_association;
#[derive(Clone)]
pub(crate) struct ReviewPullRequest {
pub title: String,
pub body: String,
pub head: String,
pub branch: String,
pub base: String,
pub base_head: String,
}
pub(crate) struct ReviewInput {
pub markdown: String,
pub body_alone: bool,
}
#[derive(serde::Deserialize)]
struct ReviewMarker {
reviewed_head: Option<String>,
pushed_head: Option<String>,
}
impl GitHub {
pub fn review_labels(&self, pr: &PullRequestUrl) -> Result<crate::labels::Labels> {
let labels = self.gh_api_items(
&format!(
"repos/{}/issues/{}/labels?per_page=100",
pr.issue.repo_slug(),
pr.issue.number
),
"",
)?;
Ok(super::named(&labels))
}
pub fn waiting_review_pull_requests(
&self,
repo: &crate::issue::Repo,
author: &str,
reserved: Option<u64>,
) -> Result<Vec<PullRequestUrl>> {
let nodes = self.gh_api_items(
&format!("repos/{}/pulls?state=open&per_page=100", repo.slug()),
"",
)?;
let mut candidates = Vec::new();
for node in &nodes {
if node["user"]["login"]
.as_str()
.is_none_or(|login| !login.eq_ignore_ascii_case(author))
|| (!(reserved.is_some() && reserved == node["number"].as_u64())
&& super::labels_of(node)?.has(crate::claim::IN_PROGRESS))
{
continue;
}
let pr = PullRequestUrl::parse(
node["html_url"]
.as_str()
.context("pull request has no URL")?,
)?;
if ensure_reviewable(&pr, node).is_err() {
continue;
}
if self.reviewed_head(
&pr,
node["head"]["sha"]
.as_str()
.context("pull request has no head")?,
)? {
continue;
}
let mut base_waiting = false;
for base_pr in nodes.iter().filter(|base| {
base["head"]["ref"] == node["base"]["ref"]
&& base["head"]["repo"]["full_name"] == node["base"]["repo"]["full_name"]
}) {
let base = PullRequestUrl::parse(
base_pr["html_url"]
.as_str()
.context("base pull request has no URL")?,
)?;
if !self.reviewed_head(
&base,
base_pr["head"]["sha"]
.as_str()
.context("base pull request has no head")?,
)? {
base_waiting = true;
break;
}
}
if base_waiting {
continue;
}
candidates.push(pr);
}
candidates.sort_by_key(|pr| pr.issue.number);
Ok(candidates)
}
fn reviewed_head(&self, pr: &PullRequestUrl, head: &str) -> Result<bool> {
Ok(self.review_markers(pr)?.iter().any(|marker| {
marker.reviewed_head.as_deref() == Some(head)
|| marker.pushed_head.as_deref() == Some(head)
}))
}
pub fn review_base_fixes(
&self,
pr: &PullRequestUrl,
snapshot: &ReviewPullRequest,
) -> Result<Vec<String>> {
let nodes = self.gh_api_items(
&format!(
"repos/{}/pulls?state=open&per_page=100",
pr.issue.repo_slug()
),
"",
)?;
let mut fixes = Vec::new();
for node in nodes {
if node["head"]["ref"].as_str() != Some(&snapshot.base)
|| node["head"]["repo"]["full_name"]
.as_str()
.is_none_or(|repo| !repo.eq_ignore_ascii_case(&pr.issue.repo_slug()))
{
continue;
}
let base = PullRequestUrl::parse(
node["html_url"]
.as_str()
.context("base pull request has no URL")?,
)?;
for marker in self.review_markers(&base)? {
if let (Some(reviewed), Some(pushed)) = (marker.reviewed_head, marker.pushed_head)
&& reviewed != pushed
{
fixes.push(pushed);
}
}
}
Ok(fixes)
}
fn review_markers(&self, pr: &PullRequestUrl) -> Result<Vec<ReviewMarker>> {
let actor = self.gh_json(&["api", "user"])?;
let actor = actor["login"]
.as_str()
.filter(|login| !login.is_empty())
.context("authenticated GitHub account has no login")?;
let comments = self.gh_api_items(
&format!(
"repos/{}/issues/{}/comments?per_page=100",
pr.issue.repo_slug(),
pr.issue.number
),
"",
)?;
let mut markers = Vec::new();
for comment in comments {
if comment["user"]["login"]
.as_str()
.is_none_or(|login| !login.eq_ignore_ascii_case(actor))
{
continue;
}
if let Some(marker) = trailing_review_marker(comment["body"].as_str().unwrap_or("")) {
markers.push(marker);
}
}
Ok(markers)
}
pub fn review_pull_request(&self, pr: &PullRequestUrl) -> Result<ReviewPullRequest> {
let node = self.gh_json(&[
"api",
&format!("repos/{}/pulls/{}", pr.issue.repo_slug(), pr.issue.number),
])?;
let text = |value: &serde_json::Value| -> Result<String> {
value
.as_str()
.map(str::to_owned)
.context("pull request metadata is missing text")
};
ensure!(
!node["merged"]
.as_bool()
.context("pull request has no merged state")?,
"pull request {} is merged",
pr.url
);
ensure!(
node["state"].as_str() == Some("open"),
"pull request {} is closed",
pr.url
);
ensure_reviewable(pr, &node)?;
Ok(ReviewPullRequest {
title: text(&node["title"])?,
body: node["body"].as_str().unwrap_or("").into(),
head: text(&node["head"]["sha"])?,
branch: text(&node["head"]["ref"])?,
base: text(&node["base"]["ref"])?,
base_head: text(&node["base"]["sha"])?,
})
}
pub fn comment_review(&self, pr: &PullRequestUrl, text: &str) -> Result<()> {
use std::io::Write;
let mut file = tempfile::NamedTempFile::new()?;
file.write_all(text.as_bytes())?;
self.gh(&[
"pr",
"comment",
&pr.url,
"--body-file",
file.path().to_str().context("comment path is not UTF-8")?,
])
}
pub fn review_input(
&self,
pr: &PullRequestUrl,
snapshot: &ReviewPullRequest,
) -> Result<ReviewInput> {
let mut markdown = format!(
"# {}\n\n{}\n\n## Pull request body\n\n{}\n",
snapshot.title, pr.url, snapshot.body
);
let mut after = None;
let mut issues = std::collections::BTreeSet::new();
loop {
let json = self.issue_query(&pr.issue, CLOSING_ISSUES, after.as_deref())?;
let page = &json["data"]["repository"]["pullRequest"]["closingIssuesReferences"];
for node in page["nodes"]
.as_array()
.context("closing issues have no nodes")?
{
let url = node["url"].as_str().context("closing issue has no URL")?;
if issues.insert(url.to_owned()) {
let issue = crate::issue::IssueUrl::parse(url)?;
markdown.push_str("\n## Linked closing issue (Spec)\n\n");
markdown.push_str(&self.issue_for_session(&issue)?);
}
}
let info = &page["pageInfo"];
if !info["hasNextPage"]
.as_bool()
.context("closing issues have no hasNextPage")?
{
break;
}
let next = info["endCursor"]
.as_str()
.context("closing issues have no endCursor")?;
ensure!(
after.as_deref() != Some(next),
"closing issues cursor did not advance"
);
after = Some(next.to_owned());
}
for (kind, suffix) in [
("issues", "comments"),
("pulls", "reviews"),
("pulls", "comments"),
] {
let comments = self.gh_api_items(
&format!(
"repos/{}/{kind}/{}/{suffix}?per_page=100",
pr.issue.repo_slug(),
pr.issue.number
),
"",
)?;
for comment in comments {
let association = comment["author_association"]
.as_str()
.context("PR comment has no author association")?;
if let Some(login) = comment["user"]["login"]
.as_str()
.filter(|_| trusted_association(association))
{
markdown.push_str(&format!(
"\n### Comment by @{login}\n\n{}\n",
comment["body"].as_str().unwrap_or("")
));
} else {
markdown.push_str("\n_A comment from an account that is not a Trusted account was left out._\n");
}
}
}
Ok(ReviewInput {
markdown,
body_alone: issues.is_empty(),
})
}
}
fn trailing_review_marker(body: &str) -> Option<ReviewMarker> {
let body = body.trim_end();
let start = body.rfind("<!-- thirdshift-review ")?;
let after = &body[start + "<!-- thirdshift-review ".len()..];
let (json, rest) = after.split_once("-->")?;
if !rest.trim().is_empty() {
return None;
}
serde_json::from_str(json.trim()).ok()
}
fn ensure_reviewable(pr: &PullRequestUrl, node: &serde_json::Value) -> Result<()> {
ensure!(
!node["draft"]
.as_bool()
.context("pull request has no draft state")?,
"pull request {} is a draft",
pr.url
);
fn text(value: &serde_json::Value) -> Result<&str> {
value
.as_str()
.context("pull request metadata is missing text")
}
let head_repo = text(&node["head"]["repo"]["full_name"])?;
let base_repo = text(&node["base"]["repo"]["full_name"])?;
ensure!(
head_repo.eq_ignore_ascii_case(base_repo)
&& base_repo.eq_ignore_ascii_case(&pr.issue.repo_slug()),
"pull request {} is from a fork",
pr.url
);
ensure!(
trusted_association(text(&node["author_association"])?),
"pull request {} author is not a Trusted account",
pr.url
);
Ok(())
}
const CLOSING_ISSUES: &str =
"query ReviewClosingIssues($owner: String!, $repo: String!, $number: Int!, $after: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 100, after: $after) {
nodes { url }
pageInfo { hasNextPage endCursor }
}
}
}
}";