use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::process::Command;
use crate::forge::{Comment, Issue, IssueState, PrState, PullRequest};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GhIssue {
number: i64,
title: String,
body: Option<String>,
state: String,
labels: Vec<GhLabel>,
author: GhAuthor,
created_at: String,
updated_at: String,
url: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GhPullRequest {
number: i64,
title: String,
body: Option<String>,
state: String,
labels: Vec<GhLabel>,
author: GhAuthor,
created_at: String,
merged_at: Option<String>,
url: String,
#[serde(default)]
comments: Vec<GhComment>,
#[serde(default)]
reviews: Vec<GhReview>,
#[serde(default, rename = "closingIssuesReferences")]
closing_issues: Vec<GhIssueRef>,
}
#[derive(Debug, Deserialize)]
struct GhLabel {
name: String,
}
#[derive(Debug, Deserialize)]
struct GhAuthor {
login: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GhComment {
author: GhAuthor,
body: String,
created_at: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GhReview {
author: GhAuthor,
body: String,
state: String,
#[serde(default)]
created_at: String,
}
#[derive(Debug, Deserialize)]
struct GhIssueRef {
number: i64,
}
pub(crate) fn check_gh_auth() -> Result<bool> {
let output = Command::new("gh")
.args(["auth", "status"])
.output()
.context("Failed to run `gh auth status`. Is `gh` CLI installed?")?;
Ok(output.status.success())
}
pub(crate) fn fetch_issue_count(repo: &str) -> Result<usize> {
let query = format!("repo:{} is:issue", repo);
let output = Command::new("gh")
.args([
"api",
"search/issues",
"-f",
&format!("q={}", query),
"--jq",
".total_count",
])
.output()
.context("Failed to run `gh api search/issues`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("gh api search/issues failed: {}", stderr);
}
let count_str = String::from_utf8_lossy(&output.stdout);
let count: usize = count_str.trim().parse().unwrap_or(0);
Ok(count)
}
pub(crate) fn fetch_pr_count(repo: &str) -> Result<usize> {
let query = format!("repo:{} is:pr", repo);
let output = Command::new("gh")
.args([
"api",
"search/issues",
"-f",
&format!("q={}", query),
"--jq",
".total_count",
])
.output()
.context("Failed to run `gh api search/issues`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("gh api search/issues failed: {}", stderr);
}
let count_str = String::from_utf8_lossy(&output.stdout);
let count: usize = count_str.trim().parse().unwrap_or(0);
Ok(count)
}
pub(crate) fn fetch_issues(repo: &str, limit: usize, since: Option<&str>) -> Result<Vec<Issue>> {
let mut cmd = Command::new("gh");
cmd.args([
"issue",
"list",
"--repo",
repo,
"--limit",
&limit.to_string(),
"--state",
"all",
"--json",
"number,title,body,state,labels,author,createdAt,updatedAt,url",
]);
if let Some(timestamp) = since {
let date = ×tamp[..10.min(timestamp.len())]; cmd.args(["--search", &format!("updated:>={}", date)]);
}
let output = cmd.output().context("Failed to run `gh issue list`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("gh issue list failed: {}", stderr);
}
let gh_issues: Vec<GhIssue> =
serde_json::from_slice(&output.stdout).context("Failed to parse GitHub issues JSON")?;
Ok(gh_issues.into_iter().map(into_issue).collect())
}
pub(crate) fn fetch_pull_requests(
repo: &str,
limit: usize,
since: Option<&str>,
) -> Result<Vec<PullRequest>> {
let mut cmd = Command::new("gh");
cmd.args([
"pr",
"list",
"--repo",
repo,
"--limit",
&limit.to_string(),
"--state",
"all",
"--json",
"number,title,body,state,labels,author,createdAt,mergedAt,url",
]);
if let Some(timestamp) = since {
let date = ×tamp[..10.min(timestamp.len())];
cmd.args(["--search", &format!("updated:>={}", date)]);
}
let output = cmd.output().context("Failed to run `gh pr list`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("gh pr list failed: {}", stderr);
}
let gh_prs: Vec<GhPullRequest> =
serde_json::from_slice(&output.stdout).context("Failed to parse GitHub PRs JSON")?;
Ok(gh_prs.into_iter().map(into_pull_request).collect())
}
pub(crate) fn fetch_issue(repo: &str, number: i64) -> Result<Issue> {
let output = Command::new("gh")
.args([
"issue",
"view",
&number.to_string(),
"--repo",
repo,
"--json",
"number,title,body,state,labels,author,createdAt,updatedAt,url",
])
.output()
.context("Failed to run `gh issue view`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("gh issue view #{} failed: {}", number, stderr);
}
let gh_issue: GhIssue =
serde_json::from_slice(&output.stdout).context("Failed to parse GitHub issue JSON")?;
Ok(into_issue(gh_issue))
}
pub(crate) fn fetch_pull_request(repo: &str, number: i64) -> Result<PullRequest> {
let output = Command::new("gh")
.args([
"pr",
"view",
&number.to_string(),
"--repo",
repo,
"--json",
"number,title,body,state,labels,author,createdAt,mergedAt,url,comments,reviews,closingIssuesReferences",
])
.output()
.context("Failed to run `gh pr view`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("gh pr view #{} failed: {}", number, stderr);
}
let gh_pr: GhPullRequest =
serde_json::from_slice(&output.stdout).context("Failed to parse GitHub PR JSON")?;
Ok(into_pull_request(gh_pr))
}
pub(crate) fn fetch_max_issue_number(repo: &str) -> Result<i64> {
let output = Command::new("gh")
.args([
"issue", "list", "--repo", repo, "--limit", "1", "--state", "all", "--json", "number",
])
.output()
.context("Failed to run `gh issue list`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("gh issue list failed: {}", stderr);
}
#[derive(serde::Deserialize)]
struct IssueNum {
number: i64,
}
let issues: Vec<IssueNum> =
serde_json::from_slice(&output.stdout).context("Failed to parse GitHub issues JSON")?;
Ok(issues.first().map(|i| i.number).unwrap_or(0))
}
fn into_issue(gh: GhIssue) -> Issue {
Issue {
number: gh.number,
title: gh.title,
body: gh.body,
state: parse_issue_state(&gh.state),
author: gh.author.login,
labels: gh.labels.into_iter().map(|l| l.name).collect(),
created_at: gh.created_at,
updated_at: gh.updated_at,
url: gh.url,
}
}
fn into_pull_request(gh: GhPullRequest) -> PullRequest {
let mut comments: Vec<Comment> = gh
.comments
.into_iter()
.map(|c| Comment {
author: c.author.login,
body: c.body,
created_at: c.created_at,
})
.collect();
for review in gh.reviews.iter() {
if !review.body.is_empty() {
comments.push(Comment {
author: review.author.login.clone(),
body: review.body.clone(),
created_at: review.created_at.clone(),
});
}
}
let approvals = gh.reviews.iter().filter(|r| r.state == "APPROVED").count() as i32;
PullRequest {
number: gh.number,
title: gh.title,
body: gh.body,
state: parse_pr_state(&gh.state, gh.merged_at.is_some()),
author: gh.author.login,
labels: gh.labels.into_iter().map(|l| l.name).collect(),
created_at: gh.created_at,
merged_at: gh.merged_at,
url: gh.url,
linked_issues: gh.closing_issues.into_iter().map(|i| i.number).collect(),
comments,
approvals,
}
}
fn parse_issue_state(state: &str) -> IssueState {
match state.to_uppercase().as_str() {
"OPEN" => IssueState::Open,
_ => IssueState::Closed,
}
}
fn parse_pr_state(state: &str, has_merged_at: bool) -> PrState {
if has_merged_at {
PrState::Merged
} else {
match state.to_uppercase().as_str() {
"OPEN" => PrState::Open,
"MERGED" => PrState::Merged,
_ => PrState::Closed,
}
}
}