use anyhow::Result;
use futures::future::join_all;
use chrono::{DateTime, Utc};
use octocrab::Octocrab;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct PendingReview {
pub repo: String,
pub pr_number: u64,
pub pr_title: String,
pub pr_author: String,
pub pr_url: String,
pub created_at: DateTime<Utc>,
pub additions: u64,
pub deletions: u64,
pub draft: bool,
pub branch: String,
}
pub async fn fetch_pr_by_number(
token: &str,
org: &str,
repos: &[String],
pr_number: u64,
) -> Result<Vec<PendingReview>> {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
let futures = repos.iter().map(|repo| {
let client = &client;
let repo_name = repo.clone();
async move {
(repo_name, client.pulls(org, repo).get(pr_number).await)
}
});
let results_vec: Vec<(String, Result<_, _>)> = join_all(futures).await;
let mut results = vec![];
for (repo_name, result) in results_vec {
match result {
Ok(pr) => {
let author = pr.user.as_ref().map(|u| u.login.clone()).unwrap_or_default();
results.push(PendingReview {
repo: repo_name,
pr_number: pr.number,
pr_title: pr.title.clone().unwrap_or_default(),
pr_author: author,
pr_url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
created_at: pr.created_at.unwrap_or_default(),
additions: pr.additions.unwrap_or(0),
deletions: pr.deletions.unwrap_or(0),
draft: pr.draft.unwrap_or(false),
branch: pr.head.label.clone().unwrap_or_default(),
});
}
Err(_) => continue,
}
}
Ok(results)
}
#[allow(clippy::too_many_arguments)]
pub async fn fetch_pending_reviews(
token: &str,
org: &str,
repos: &[String],
username: &str,
teams: &[String],
include_mine: bool,
include_drafts: bool,
exclude_prefixes: &[String],
crew_members: &[String],
) -> Result<Vec<PendingReview>> {
#[derive(Clone)]
struct CandidatePr {
repo: String,
number: u64,
title: String,
author: String,
url: String,
created_at: DateTime<Utc>,
draft: bool,
branch: String,
}
let mut candidates: Vec<CandidatePr> = Vec::new();
let repo_futures = repos.iter().map(|repo| {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()
.unwrap();
let repo = repo.clone();
async move {
client
.pulls(org, &repo)
.list()
.state(octocrab::params::State::Open)
.per_page(50)
.send()
.await
.map(|prs| (repo, prs.items))
}
});
let repo_results: Vec<(String, Vec<_>)> = join_all(repo_futures)
.await
.into_iter()
.filter_map(|result| match result {
Ok((repo, items)) => Some((repo, items)),
Err(e) => {
eprintln!("Warning: Failed to fetch PRs from a repo: {}", e);
None
}
})
.collect();
for (repo, prs) in repo_results {
for pr in prs {
if !include_drafts && pr.draft.unwrap_or(false) {
continue;
}
let title = pr.title.clone().unwrap_or_default();
if exclude_prefixes.iter().any(|p| !p.is_empty() && title.starts_with(p)) {
continue;
}
let author = pr.user.as_ref().map(|u| u.login.as_str()).unwrap_or("");
if !crew_members.is_empty() {
if !crew_members.iter().any(|m| m == author) {
continue;
}
} else {
if !include_mine && author == username {
continue;
}
let user_requested = pr
.requested_reviewers
.as_deref()
.unwrap_or(&[])
.iter()
.any(|r| r.login == username);
let team_requested = if teams.is_empty() {
false
} else {
pr.requested_teams
.as_deref()
.unwrap_or(&[])
.iter()
.any(|t| teams.contains(&t.slug.to_lowercase()))
};
if !user_requested && !team_requested {
continue;
}
}
candidates.push(CandidatePr {
repo: repo.clone(),
number: pr.number,
title,
author: author.to_string(),
url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
created_at: pr.created_at.unwrap_or_default(),
draft: pr.draft.unwrap_or(false),
branch: pr.head.label.clone().unwrap_or_default(),
});
}
}
let detail_futures = candidates.iter().map(|c| {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()
.unwrap();
let repo = c.repo.clone();
let number = c.number;
async move {
client.pulls(org, &repo).get(number).await
}
});
let details: Vec<Result<_, _>> = join_all(detail_futures).await;
let mut pending: Vec<PendingReview> = Vec::new();
for (candidate, detail_result) in candidates.into_iter().zip(details) {
match detail_result {
Ok(detail) => {
pending.push(PendingReview {
repo: candidate.repo,
pr_number: candidate.number,
pr_title: candidate.title,
pr_author: candidate.author,
pr_url: candidate.url,
created_at: candidate.created_at,
additions: detail.additions.unwrap_or(0),
deletions: detail.deletions.unwrap_or(0),
draft: candidate.draft,
branch: candidate.branch,
});
}
Err(e) => {
eprintln!("Warning: Failed to fetch details for PR #{} in {}: {}", candidate.number, candidate.repo, e);
}
}
}
pending.sort_by_key(|r| r.created_at);
Ok(pending)
}
pub async fn fetch_my_open_prs(
token: &str,
org: &str,
repos: &[String],
username: &str,
include_drafts: bool,
exclude_prefixes: &[String],
) -> Result<Vec<PendingReview>> {
#[derive(Clone)]
struct CandidatePr {
repo: String,
number: u64,
title: String,
author: String,
url: String,
created_at: DateTime<Utc>,
draft: bool,
branch: String,
}
let mut candidates: Vec<CandidatePr> = Vec::new();
let repo_futures = repos.iter().map(|repo| {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()
.unwrap();
let repo = repo.clone();
async move {
client
.pulls(org, &repo)
.list()
.state(octocrab::params::State::Open)
.per_page(50)
.send()
.await
.map(|prs| (repo, prs.items))
}
});
let repo_results: Vec<(String, Vec<_>)> = join_all(repo_futures)
.await
.into_iter()
.filter_map(|result| match result {
Ok((repo, items)) => Some((repo, items)),
Err(e) => {
eprintln!("Warning: Failed to fetch PRs from a repo: {}", e);
None
}
})
.collect();
for (repo, prs) in repo_results {
for pr in prs {
if !include_drafts && pr.draft.unwrap_or(false) {
continue;
}
let title = pr.title.clone().unwrap_or_default();
if exclude_prefixes.iter().any(|p| !p.is_empty() && title.starts_with(p)) {
continue;
}
let author = pr.user.as_ref().map(|u| u.login.as_str()).unwrap_or("");
if author != username {
continue;
}
candidates.push(CandidatePr {
repo: repo.clone(),
number: pr.number,
title,
author: author.to_string(),
url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
created_at: pr.created_at.unwrap_or_default(),
draft: pr.draft.unwrap_or(false),
branch: pr.head.label.clone().unwrap_or_default(),
});
}
}
let detail_futures = candidates.iter().map(|c| {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()
.unwrap();
let repo = c.repo.clone();
let number = c.number;
async move {
client.pulls(org, &repo).get(number).await
}
});
let details: Vec<Result<_, _>> = join_all(detail_futures).await;
let mut my_prs: Vec<PendingReview> = Vec::new();
for (candidate, detail_result) in candidates.into_iter().zip(details) {
match detail_result {
Ok(detail) => {
my_prs.push(PendingReview {
repo: candidate.repo,
pr_number: candidate.number,
pr_title: candidate.title,
pr_author: candidate.author,
pr_url: candidate.url,
created_at: candidate.created_at,
additions: detail.additions.unwrap_or(0),
deletions: detail.deletions.unwrap_or(0),
draft: candidate.draft,
branch: candidate.branch,
});
}
Err(e) => {
eprintln!("Warning: Failed to fetch details for PR #{} in {}: {}", candidate.number, candidate.repo, e);
}
}
}
my_prs.sort_by_key(|r| r.created_at);
Ok(my_prs)
}
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PullRequestFile {
pub filename: String,
pub status: String,
pub additions: u64,
pub deletions: u64,
}
pub async fn fetch_pr_files(
token: &str,
org: &str,
repo: &str,
pr_number: u64,
) -> Result<Vec<PullRequestFile>> {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
let files: Vec<PullRequestFile> = client
.pulls(org, repo)
.list_files(pr_number)
.await?
.into_iter()
.map(|f| {
let status_str = match f.status {
octocrab::models::repos::DiffEntryStatus::Added => "added",
octocrab::models::repos::DiffEntryStatus::Removed => "removed",
octocrab::models::repos::DiffEntryStatus::Modified => "modified",
octocrab::models::repos::DiffEntryStatus::Renamed => "renamed",
octocrab::models::repos::DiffEntryStatus::Copied => "copied",
octocrab::models::repos::DiffEntryStatus::Changed => "changed",
octocrab::models::repos::DiffEntryStatus::Unchanged => "unchanged",
_ => "unknown",
};
PullRequestFile {
filename: f.filename,
status: status_str.to_string(),
additions: f.additions,
deletions: f.deletions,
}
})
.collect();
Ok(files)
}
#[derive(Debug, Clone, Serialize)]
pub struct PullRequestLabel {
pub id: String,
pub name: String,
pub color: String,
pub description: Option<String>,
}
pub async fn fetch_pr_labels(
token: &str,
org: &str,
repo: &str,
pr_number: u64,
) -> Result<Vec<PullRequestLabel>> {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
let labels: Vec<PullRequestLabel> = client
.pulls(org, repo)
.get(pr_number)
.await?
.labels
.unwrap_or_default()
.into_iter()
.map(|l| PullRequestLabel {
id: l.id.to_string(),
name: l.name,
color: l.color,
description: l.description,
})
.collect();
Ok(labels)
}
#[derive(Debug, Clone)]
pub struct PullRequestDiff {
pub filename: String,
pub status: String, pub additions: u64,
pub deletions: u64,
pub patch: Option<String>,
pub language: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct MergeConflictStatus {
pub repo: String,
pub pr_number: u64,
pub pr_title: String,
pub has_conflicts: bool,
pub mergeable: Option<bool>,
pub rebaseable: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CiStatus {
pub repo: String,
pub pr_number: u64,
pub pr_title: String,
pub head_sha: String,
pub overall_status: String, pub checks: Vec<CiCheck>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CiCheck {
pub name: String,
pub status: String, pub conclusion: Option<String>, pub app_name: String, pub started_at: Option<String>,
pub completed_at: Option<String>,
}
pub async fn fetch_pr_diff(
token: &str,
org: &str,
repo: &str,
pr_number: u64,
) -> Result<Vec<PullRequestDiff>> {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
let files: Vec<PullRequestDiff> = client
.pulls(org, repo)
.list_files(pr_number)
.await?
.into_iter()
.map(|f| {
let status_str = match f.status {
octocrab::models::repos::DiffEntryStatus::Added => "added",
octocrab::models::repos::DiffEntryStatus::Removed => "removed",
octocrab::models::repos::DiffEntryStatus::Modified => "modified",
octocrab::models::repos::DiffEntryStatus::Renamed => "renamed",
octocrab::models::repos::DiffEntryStatus::Copied => "copied",
octocrab::models::repos::DiffEntryStatus::Changed => "changed",
octocrab::models::repos::DiffEntryStatus::Unchanged => "unchanged",
_ => "unknown",
};
let language = f.filename.split('.').next_back().map(|ext| {
match ext {
"ts" | "tsx" => "typescript",
"js" | "jsx" | "mjs" | "cjs" => "javascript",
"py" => "python",
"go" => "go",
"java" => "java",
"rb" => "ruby",
"cs" => "csharp",
"cpp" | "cc" | "cxx" => "cpp",
"c" | "h" => "c",
"swift" => "swift",
"kt" | "kts" => "kotlin",
"scala" => "scala",
"rs" => "rust",
"php" => "php",
"ex" | "exs" => "elixir",
"erl" => "erlang",
"hs" => "haskell",
"ml" | "mli" => "ocaml",
"fs" | "fsx" => "fsharp",
"lua" => "lua",
"r" => "r",
"sql" => "sql",
"sh" | "bash" | "zsh" => "bash",
"ps1" => "powershell",
"yml" | "yaml" => "yaml",
"toml" => "toml",
"json" => "json",
"xml" => "xml",
"html" | "htm" => "html",
"css" | "scss" | "sass" | "less" => "css",
"md" | "markdown" => "markdown",
"dockerfile" => "dockerfile",
"tf" => "hcl",
"proto" => "protobuf",
_ => ext,
}.to_string()
});
PullRequestDiff {
filename: f.filename,
status: status_str.to_string(),
additions: f.additions,
deletions: f.deletions,
patch: f.patch,
language,
}
})
.collect();
Ok(files)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ReviewActivity {
pub repo: String,
pub pr_number: u64,
pub pr_title: String,
pub author: String,
pub reviewed_at: chrono::DateTime<chrono::Utc>,
pub state: String, }
pub async fn fetch_my_review_activity(
token: &str,
org: &str,
repos: &[String],
username: &str,
days: u32,
) -> Result<Vec<ReviewActivity>> {
let since = chrono::Utc::now() - chrono::Duration::days(days as i64);
let pr_futures = repos.iter().map(|repo| {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()
.unwrap();
let org = org.to_string();
let repo = repo.clone();
async move {
let prs = client
.pulls(&org, &repo)
.list()
.state(octocrab::params::State::All)
.per_page(100)
.send()
.await
.unwrap_or_default();
let candidates: Vec<_> = prs.items
.into_iter()
.filter(|pr| {
let updated_at = pr.updated_at
.unwrap_or_else(|| pr.created_at.unwrap_or_else(chrono::Utc::now));
updated_at >= since
})
.map(|pr| {
(repo.clone(), pr.number, pr.title.clone().unwrap_or_default(), pr.user.as_ref().map(|u| u.login.clone()).unwrap_or_default())
})
.collect();
(org, repo, candidates)
}
});
#[allow(clippy::type_complexity)]
let repo_results: Vec<(String, String, Vec<(String, u64, String, String)>)> = join_all(pr_futures).await;
let timeline_futures: Vec<_> = repo_results.iter().flat_map(|(org, repo, prs)| {
prs.iter().map(|(_, pr_number, _, _)| {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()
.unwrap();
let org = org.clone();
let repo = repo.clone();
let pr_number = *pr_number;
async move {
let timeline: Vec<serde_json::Value> = client
.get(
format!(
"/repos/{}/{}/issues/{}/timeline",
org, repo, pr_number
),
None::<&str>,
)
.await
.unwrap_or_default();
(pr_number, repo, timeline)
}
}).collect::<Vec<_>>()
}).collect();
let timeline_results: Vec<(u64, String, Vec<serde_json::Value>)> = join_all(timeline_futures).await;
let mut all_reviews = Vec::new();
for (pr_number, repo, timeline) in timeline_results {
let pr_info = repo_results.iter()
.find(|(_, r, _)| r == &repo)
.and_then(|(_, _, prs)| prs.iter().find(|(_, num, _, _)| *num == pr_number));
let (pr_title, pr_author) = match pr_info {
Some((_, _, title, author)) => (title.clone(), author.clone()),
None => continue,
};
for event in timeline {
if event.get("event").and_then(|e| e.as_str()) == Some("pull_request_review") {
if let Some(user_obj) = event.get("user") {
if user_obj.get("login").and_then(|l| l.as_str()) == Some(username) {
let reviewed_at = event
.get("submitted_at")
.and_then(|t| t.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(chrono::Utc::now);
let state = event
.get("state")
.and_then(|s| s.as_str())
.unwrap_or("COMMENTED")
.to_string();
all_reviews.push(ReviewActivity {
repo,
pr_number,
pr_title,
author: pr_author.clone(),
reviewed_at,
state,
});
break; }
}
}
}
}
all_reviews.sort_by(|a, b| b.reviewed_at.cmp(&a.reviewed_at));
Ok(all_reviews)
}
pub async fn fetch_merge_conflict_status(
token: &str,
org: &str,
reviews: &[PendingReview],
) -> Result<Vec<MergeConflictStatus>> {
let fetch_tasks: Vec<(String, u64)> = reviews
.iter()
.map(|r| (r.repo.clone(), r.pr_number))
.collect();
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
let futures = fetch_tasks.iter().map(|(repo, pr_number)| {
let client = client.clone();
let repo = repo.clone();
let pr_number = *pr_number;
async move {
client.pulls(org, &repo).get(pr_number).await
}
});
let results_vec: Vec<Result<_, _>> = join_all(futures).await;
let mut results = Vec::new();
for ((repo, pr_number), result) in fetch_tasks.into_iter().zip(results_vec.into_iter()) {
match result {
Ok(pr) => {
let has_conflicts = pr.mergeable == Some(false);
results.push(MergeConflictStatus {
repo,
pr_number,
pr_title: pr.title.unwrap_or_default(),
has_conflicts,
mergeable: pr.mergeable,
rebaseable: pr.rebaseable,
});
}
Err(e) => {
eprintln!("Warning: Failed to fetch PR #{} in {}: {}", pr_number, repo, e);
}
}
}
Ok(results)
}
pub async fn fetch_ci_status(
token: &str,
org: &str,
reviews: &[PendingReview],
) -> Result<Vec<CiStatus>> {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
let futures = reviews.iter().map(|review| {
let client = client.clone();
let org = org.to_string();
let repo = review.repo.clone();
let pr_number = review.pr_number;
async move {
let pr = client.pulls(&org, &repo).get(pr_number).await?;
let head_sha = pr.head.sha.clone();
let pr_title = pr.title.clone().unwrap_or_default();
let status_url = format!(
"/repos/{}/{}/commits/{}/status",
org, repo, head_sha
);
let check_runs_url = format!(
"/repos/{}/{}/commits/{}/check-runs",
org, repo, head_sha
);
#[derive(serde::Deserialize)]
struct CombinedStatus {
state: String,
}
#[derive(serde::Deserialize)]
struct CheckRunsResponse {
#[allow(dead_code)]
total_count: u32,
check_runs: Vec<CheckRunDto>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct CheckRunDto {
name: String,
status: String,
conclusion: Option<String>,
app_name: String,
started_at: Option<String>,
completed_at: Option<String>,
}
let (overall_status, check_runs_result) = tokio::join!(
client.get::<CombinedStatus, _, _>(&status_url, None::<&str>),
client.get::<CheckRunsResponse, _, _>(&check_runs_url, None::<&str>),
);
let overall_status = overall_status
.map(|s: CombinedStatus| s.state)
.unwrap_or_else(|_| "unknown".to_string());
let checks: Vec<CiCheck> = check_runs_result
.map(|response: CheckRunsResponse| {
response.check_runs.into_iter().map(|cr| {
CiCheck {
name: cr.name,
status: cr.status,
conclusion: cr.conclusion,
app_name: cr.app_name,
started_at: cr.started_at,
completed_at: cr.completed_at,
}
}).collect()
})
.unwrap_or_default();
Ok::<CiStatus, anyhow::Error>(CiStatus {
repo: repo.clone(),
pr_number,
pr_title,
head_sha,
overall_status,
checks,
})
}
});
let results: Vec<Result<CiStatus>> = join_all(futures).await;
let mut ci_statuses = Vec::new();
for (review, result) in reviews.iter().zip(results.into_iter()) {
match result {
Ok(status) => ci_statuses.push(status),
Err(e) => {
eprintln!("Warning: Failed to fetch CI status for #{} in {}: {}", review.pr_number, review.repo, e);
}
}
}
Ok(ci_statuses)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Mention {
pub repo: String,
pub pr_number: u64,
pub pr_title: String,
pub author: String,
pub pr_url: String,
pub unread: bool,
pub reason: String,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub last_comment_preview: String,
}
pub async fn fetch_mentions(
token: &str,
_username: &str,
unread_only: bool,
limit: usize,
) -> Result<Vec<Mention>> {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
#[derive(serde::Deserialize)]
struct Notification {
#[allow(dead_code)]
id: String,
unread: bool,
reason: String,
updated_at: String,
#[serde(rename = "subject")]
subject: NotificationSubject,
repository: NotificationRepo,
}
#[derive(serde::Deserialize)]
struct NotificationSubject {
title: String,
#[serde(rename = "type")]
notification_type: String,
url: Option<String>,
}
#[derive(serde::Deserialize)]
struct NotificationRepo {
full_name: String,
}
let all_notifications: Vec<Notification> = client
.get("notifications", Some(&[("per_page", "100")]))
.await
.unwrap_or_default();
let mut mentions = Vec::new();
for notification in all_notifications {
if notification.subject.notification_type != "PullRequest"
&& notification.subject.notification_type != "Issue" {
continue;
}
let relevant_reasons = ["mention", "review_requested", "assign", "author", "team_mention", "cm"];
if !relevant_reasons.contains(¬ification.reason.as_str()) {
continue;
}
if unread_only && !notification.unread {
continue;
}
let (org_repo, pr_number): (String, u64) = if let Some(ref url) = notification.subject.url {
let parts: Vec<&str> = url.split('/').collect();
if parts.len() >= 2 {
let org = parts.get(4).unwrap_or(&"");
let repo = parts.get(5).unwrap_or(&"");
let num = parts.get(7).and_then(|s| s.parse().ok()).unwrap_or(0);
(format!("{}/{}", org, repo), num)
} else {
(notification.repository.full_name.clone(), 0)
}
} else {
(notification.repository.full_name.clone(), 0)
};
let pr_url = notification
.subject
.url
.as_ref()
.map(|url| {
url.replace("https://api.github.com/repos/", "https://github.com/")
.replace("/pulls/", "/pull/")
})
.unwrap_or_else(|| {
format!(
"https://github.com/{}/pull/{}",
notification.repository.full_name, pr_number
)
});
let updated_at = chrono::DateTime::parse_from_rfc3339(¬ification.updated_at)
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(|_| chrono::Utc::now());
let repo_parts: Vec<&str> = org_repo.split('/').collect();
let _org = repo_parts.first().unwrap_or(&"");
let _repo = repo_parts.get(1).unwrap_or(&"");
mentions.push(Mention {
repo: org_repo,
pr_number,
pr_title: notification.subject.title,
author: String::new(), pr_url,
unread: notification.unread,
reason: notification.reason,
updated_at,
last_comment_preview: String::new(),
});
}
let max_to_fetch = std::cmp::min(mentions.len(), limit * 2);
mentions.truncate(max_to_fetch);
let detail_futures = mentions.iter().filter(|m| m.pr_number > 0).map(|m| {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()
.unwrap();
let repo = m.repo.clone();
let pr_number = m.pr_number;
async move {
let parts: Vec<&str> = repo.split('/').collect();
let org = parts.first().unwrap_or(&"");
let repo_name = parts.get(1).unwrap_or(&"");
client.pulls(org.to_string(), repo_name.to_string()).get(pr_number).await
}
});
let detail_results: Vec<Result<_, _>> = join_all(detail_futures).await;
let mut author_map: std::collections::HashMap<(String, u64), String> = std::collections::HashMap::new();
for (mention, result) in mentions.iter().zip(detail_results.into_iter()) {
if mention.pr_number > 0 {
if let Ok(detail) = result {
let author = detail.user.as_ref().map(|u| u.login.clone()).unwrap_or_default();
author_map.insert((mention.repo.clone(), mention.pr_number), author);
}
}
}
for mention in &mut mentions {
if let Some(author) = author_map.get(&(mention.repo.clone(), mention.pr_number)) {
mention.author = author.clone();
} else if mention.pr_number > 0 {
mention.author = "unknown".to_string();
}
}
mentions.truncate(limit);
mentions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(mentions)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct RateLimitInfo {
pub resource: String,
pub limit: u32,
pub remaining: u32,
pub reset: chrono::DateTime<chrono::Utc>,
pub reset_in_seconds: i64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct HealthStatus {
pub rate_limits: Vec<RateLimitInfo>,
pub server_time: chrono::DateTime<chrono::Utc>,
pub authenticated: bool,
pub username: Option<String>,
pub rate_limit_warning: bool,
}
pub async fn fetch_health_status(token: &str) -> Result<HealthStatus> {
let client = octocrab::Octocrab::builder()
.personal_token(token.to_string())
.build()?;
#[derive(serde::Deserialize)]
struct RateLimitResponse {
resources: RateResources,
rate: RateLimitEntry,
}
#[derive(serde::Deserialize)]
struct RateResources {
#[allow(dead_code)]
#[serde(rename = "core")]
core: RateLimitEntry,
#[serde(rename = "search")]
search: RateLimitEntry,
#[serde(rename = "graphql")]
graphql: Option<RateLimitEntry>,
}
#[derive(serde::Deserialize)]
struct RateLimitEntry {
limit: u32,
remaining: u32,
reset: u64,
#[allow(dead_code)]
#[serde(rename = "used")]
used: u32,
#[allow(dead_code)]
#[serde(rename = "resource")]
resource: Option<String>,
}
let rate_limits: Vec<RateLimitInfo> = client
.get("rate_limit", None::<&str>)
.await
.map(|response: RateLimitResponse| {
let now = chrono::Utc::now();
let mut limits = vec![];
let reset_core = chrono::DateTime::from_timestamp(response.rate.reset as i64, 0)
.unwrap_or(now);
limits.push(RateLimitInfo {
resource: "core".to_string(),
limit: response.rate.limit,
remaining: response.rate.remaining,
reset: reset_core,
reset_in_seconds: (reset_core - now).num_seconds(),
});
let reset_search = chrono::DateTime::from_timestamp(response.resources.search.reset as i64, 0)
.unwrap_or(now);
limits.push(RateLimitInfo {
resource: "search".to_string(),
limit: response.resources.search.limit,
remaining: response.resources.search.remaining,
reset: reset_search,
reset_in_seconds: (reset_search - now).num_seconds(),
});
if let Some(graphql) = response.resources.graphql {
let reset_graphql = chrono::DateTime::from_timestamp(graphql.reset as i64, 0)
.unwrap_or(now);
limits.push(RateLimitInfo {
resource: "graphql".to_string(),
limit: graphql.limit,
remaining: graphql.remaining,
reset: reset_graphql,
reset_in_seconds: (reset_graphql - now).num_seconds(),
});
}
limits
})
.unwrap_or_default();
#[derive(serde::Deserialize)]
struct UserResponse {
login: String,
}
let (authenticated, username) = match client.get::<UserResponse, _, _>("user", None::<&str>).await {
Ok(user) => (true, Some(user.login)),
Err(_) => (false, None),
};
let server_time = chrono::Utc::now();
let rate_limit_warning = rate_limits.iter().any(|r| {
r.limit > 0 && (r.remaining as f64 / r.limit as f64) < 0.1
});
Ok(HealthStatus {
rate_limits,
server_time,
authenticated,
username,
rate_limit_warning,
})
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TimelineEvent {
pub event: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub actor: Option<String>,
pub data: serde_json::Value,
}
pub async fn fetch_pr_timeline(
token: &str,
org: &str,
repo: &str,
pr_number: u64,
) -> Result<Vec<TimelineEvent>> {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
let timeline_url = format!(
"/repos/{}/{}/issues/{}/timeline",
org, repo, pr_number
);
let events: Vec<serde_json::Value> = client
.get(&timeline_url, Some(&[("per_page", "100")]))
.await
.unwrap_or_default();
let mut timeline = Vec::new();
for event in events {
let event_type = event.get("event")
.and_then(|e| e.as_str())
.unwrap_or("unknown")
.to_string();
let created_at = event.get("created_at")
.and_then(|t| t.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(chrono::Utc::now);
let actor = event.get("actor")
.or_else(|| event.get("user"))
.and_then(|a| a.get("login"))
.and_then(|l| l.as_str())
.map(String::from);
let mut data = serde_json::json!({});
match event_type.as_str() {
"PullRequestReview" => {
if let Some(state) = event.get("state").and_then(|s| s.as_str()) {
data["review_state"] = serde_json::json!(state);
}
if let Some(body) = event.get("body").and_then(|b| b.as_str()) {
let preview: String = body.chars().take(200).collect();
data["body_preview"] = serde_json::json!(preview);
}
}
"Comment" | "IssueComment" => {
if let Some(body) = event.get("body").and_then(|b| b.as_str()) {
let preview: String = body.chars().take(200).collect();
data["body_preview"] = serde_json::json!(preview);
}
}
"labeled" => {
if let Some(label) = event.get("label").and_then(|l| l.get("name")) {
data["label"] = label.clone();
}
}
"unlabeled" => {
if let Some(label) = event.get("label").and_then(|l| l.get("name")) {
data["label"] = label.clone();
}
}
"assigned" | "unassigned" => {
if let Some(assignee) = event.get("assignee").and_then(|a| a.get("login")) {
data["assignee"] = assignee.clone();
}
}
"merged" => {
if let Some(merge_commit_sha) = event.get("merge_commit_sha") {
data["merge_commit_sha"] = merge_commit_sha.clone();
}
}
"closed" => {
if let Some(merged) = event.get("merged") {
data["merged"] = merged.clone();
}
}
_ => {
if let Some(label) = event.get("label").and_then(|l| l.get("name")) {
data["label"] = label.clone();
}
}
}
timeline.push(TimelineEvent {
event: event_type,
created_at,
actor,
data,
});
}
timeline.reverse();
Ok(timeline)
}
pub async fn add_pr_reaction(
token: &str,
org: &str,
repo: &str,
pr_number: u64,
reaction: &str,
) -> Result<()> {
let client = octocrab::Octocrab::builder()
.personal_token(token.to_string())
.build()?;
let content = match reaction {
"+1" | "thumbsup" | "thumbs_up" => "+1",
"-1" | "thumbsdown" | "thumbs_down" => "-1",
"laugh" | "laughed" | "lol" => "laugh",
"confused" | "unsure" => "confused",
"heart" | "love" => "heart",
"hooray" | "party" | "celebrate" => "hooray",
"rocket" | "rockets" => "rocket",
"eyes" | "looking" | "👀" => "eyes",
_ => reaction, };
let url = format!(
"/repos/{}/{}/issues/{}/reactions",
org, repo, pr_number
);
#[derive(serde::Serialize)]
struct ReactionPayload {
content: String,
}
let _: serde_json::Value = client
.post(&url, Some(&ReactionPayload { content: content.to_string() })) .await?;
Ok(())
}
pub async fn has_user_commented(
token: &str,
org: &str,
repo: &str,
pr_number: u64,
username: &str,
) -> Result<bool> {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
let comments = client.issues(org, repo)
.list_comments(pr_number)
.per_page(100)
.send()
.await?;
for comment in comments {
let user_login = comment.user.login;
if user_login.to_lowercase() == username.to_lowercase() {
return Ok(true);
}
}
Ok(false)
}
pub async fn fetch_prs_user_commented_on(
token: &str,
org: &str,
repos: &[String],
username: &str,
) -> Result<Vec<PendingReview>> {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
#[derive(Clone)]
struct CandidatePr {
repo: String,
number: u64,
title: String,
author: String,
url: String,
created_at: DateTime<Utc>,
draft: bool,
branch: String,
}
let repo_futures = repos.iter().map(|repo| {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()
.unwrap();
let repo = repo.clone();
let org = org.to_string();
async move {
let prs = client.pulls(&org, &repo)
.list()
.state(octocrab::params::State::Open)
.per_page(100)
.send()
.await
.unwrap_or_default();
prs.items.into_iter().map(move |pr| {
CandidatePr {
repo: repo.clone(),
number: pr.number,
title: pr.title.unwrap_or_default(),
author: pr.user.as_ref().map(|u| u.login.clone()).unwrap_or_default(),
url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
created_at: pr.created_at.unwrap_or_default(),
draft: pr.draft.unwrap_or(false),
branch: pr.head.label.clone().unwrap_or_default(),
}
}).collect::<Vec<_>>()
}
});
let all_candidates: Vec<CandidatePr> = join_all(repo_futures)
.await
.into_iter()
.flatten()
.collect();
let check_futures = all_candidates.iter().map(|c| {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()
.unwrap();
let repo = c.repo.clone();
let org = org.to_string();
let number = c.number;
let username = username.to_string();
async move {
let comments = client.issues(&org, &repo)
.list_comments(number)
.per_page(100)
.send()
.await
.unwrap_or_default();
let all_comments: Vec<_> = comments.into_iter().collect();
let user_commented = all_comments.iter().any(|c| c.user.login == username);
(c.clone(), user_commented)
}
});
let results: Vec<(CandidatePr, bool)> = join_all(check_futures).await;
let commented_prs: Vec<PendingReview> = results
.into_iter()
.filter(|(_, commented)| *commented)
.map(|(c, _)| PendingReview {
repo: c.repo,
pr_number: c.number,
pr_title: c.title,
pr_author: c.author,
pr_url: c.url,
created_at: c.created_at,
additions: 0,
deletions: 0,
draft: c.draft,
branch: c.branch,
})
.collect();
Ok(commented_prs)
}