use anyhow::Result;
use chrono::{DateTime, Utc};
use futures::future::join_all;
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 mut results = vec![];
for repo in repos {
match client.pulls(org, repo).get(pr_number).await {
Ok(pr) => {
let author = pr.user.as_ref().map(|u| u.login.clone()).unwrap_or_default();
results.push(PendingReview {
repo: repo.clone(),
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) as u64,
deletions: pr.deletions.unwrap_or(0) as u64,
draft: pr.draft.unwrap_or(false),
branch: pr.head.label.clone().unwrap_or_default(),
});
}
Err(_) => continue,
}
}
Ok(results)
}
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>> {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
let mut pending = vec![];
for repo in repos {
let prs = client
.pulls(org, repo)
.list()
.state(octocrab::params::State::Open)
.per_page(50)
.send()
.await?;
for pr in prs.items {
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("");
// --crew mode: only show PRs authored by crew members
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;
}
}
// Fetch individual PR to get diff stats
let detail = client.pulls(org, repo).get(pr.number).await?;
pending.push(PendingReview {
repo: repo.clone(),
pr_number: pr.number,
pr_title: title.clone(),
pr_author: author.to_string(),
pr_url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
created_at: pr.created_at.unwrap_or_default(),
additions: detail.additions.unwrap_or(0) as u64,
deletions: detail.deletions.unwrap_or(0) as u64,
draft: pr.draft.unwrap_or(false),
branch: pr.head.label.clone().unwrap_or_default(),
});
}
}
// Sort: oldest first (most urgent)
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>> {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
let mut my_prs = vec![];
for repo in repos {
let prs = client
.pulls(org, repo)
.list()
.state(octocrab::params::State::Open)
.per_page(50)
.send()
.await?;
for pr in prs.items {
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;
}
// Fetch individual PR to get diff stats
let detail = client.pulls(org, repo).get(pr.number).await?;
my_prs.push(PendingReview {
repo: repo.clone(),
pr_number: pr.number,
pr_title: title.clone(),
pr_author: author.to_string(),
pr_url: pr.html_url.map(|u| u.to_string()).unwrap_or_default(),
created_at: pr.created_at.unwrap_or_default(),
additions: detail.additions.unwrap_or(0) as u64,
deletions: detail.deletions.unwrap_or(0) as u64,
draft: pr.draft.unwrap_or(false),
branch: pr.head.label.clone().unwrap_or_default(),
});
}
}
// Sort: oldest first (most urgent)
my_prs.sort_by_key(|r| r.created_at);
Ok(my_prs)
}
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
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)
}
/// Represents a single file's diff with metadata.
#[derive(Debug, Clone)]
pub struct PullRequestDiff {
pub filename: String,
pub status: String, // "added", "removed", "modified", "renamed"
pub additions: u64,
pub deletions: u64,
pub patch: Option<String>,
pub language: Option<String>,
}
/// Represents merge conflict status for a PR
#[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>,
}
/// Represents CI check run status for a commit
#[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, // "success", "failure", "pending", "error", "cancelled"
pub checks: Vec<CiCheck>,
}
/// Individual CI check within a commit
#[derive(Debug, Clone, Serialize)]
pub struct CiCheck {
pub name: String,
pub status: String, // "completed", "in_progress", "queued", "pending", "waiting", "requested"
pub conclusion: Option<String>, // "success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required"
pub app_name: String, // "GitHub Actions", "CircleCI", "Jenkins", etc.
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",
};
// Extract language from filename extension
let language = f.filename.split('.').last().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)
}
/// Represents a review event from GitHub history
#[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, // "APPROVED", "CHANGES_REQUESTED", "COMMENTED"
}
pub async fn fetch_my_review_activity(
token: &str,
org: &str,
repos: &[String],
username: &str,
days: u32,
) -> Result<Vec<ReviewActivity>> {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
let since = chrono::Utc::now() - chrono::Duration::days(days as i64);
let mut all_reviews = vec![];
for repo in repos {
// List all PRs (open + closed) and check if the user reviewed them
let prs = client
.pulls(org, repo)
.list()
.state(octocrab::params::State::All)
.per_page(100)
.send()
.await?;
for pr in prs.items {
// Check if this PR was updated within our window
let updated_at = pr.updated_at
.unwrap_or_else(|| pr.created_at.unwrap_or_else(chrono::Utc::now));
if updated_at < since {
continue;
}
// Get the timeline (includes reviews) for this PR
let timeline: Vec<serde_json::Value> = client
.get(
format!(
"/repos/{}/{}/issues/{}/timeline",
org, repo, pr.number
),
None::<&str>,
)
.await
.unwrap_or_default();
for event in timeline {
// Look for "PullRequestReview" events by our username
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(updated_at);
let state = event
.get("state")
.and_then(|s| s.as_str())
.unwrap_or("COMMENTED")
.to_string();
let author = pr.user.as_ref()
.map(|u| u.login.clone())
.unwrap_or_default();
all_reviews.push(ReviewActivity {
repo: repo.clone(),
pr_number: pr.number,
pr_title: pr.title.clone().unwrap_or_default(),
author,
reviewed_at,
state,
});
break; // one review per user per PR
}
}
}
}
}
}
// Sort by most recent first
all_reviews.sort_by(|a, b| b.reviewed_at.cmp(&a.reviewed_at));
Ok(all_reviews)
}
/// Fetch merge conflict status for a list of pending reviews
pub async fn fetch_merge_conflict_status(
token: &str,
org: &str,
reviews: &[PendingReview],
) -> Result<Vec<MergeConflictStatus>> {
let client = Octocrab::builder()
.personal_token(token.to_string())
.build()?;
let mut results = Vec::new();
// Fetch PR details to get mergeable status
// Group by repo to minimize API calls per repo
use std::collections::HashMap;
let mut by_repo: HashMap<String, Vec<u64>> = HashMap::new();
for review in reviews {
by_repo.entry(review.repo.clone())
.or_insert_with(Vec::new)
.push(review.pr_number);
}
for (repo, pr_numbers) in by_repo {
for pr_number in pr_numbers {
match client.pulls(org, &repo).get(pr_number).await {
Ok(pr) => {
let has_conflicts = pr.mergeable == Some(false);
let mergeable_status = MergeConflictStatus {
repo: repo.clone(),
pr_number,
pr_title: pr.title.unwrap_or_default(),
has_conflicts,
mergeable: pr.mergeable,
rebaseable: pr.rebaseable,
};
results.push(mergeable_status);
}
Err(e) => {
// Log but don't fail - just skip this PR
eprintln!("Warning: Failed to fetch PR #{} in {}: {}", pr_number, repo, e);
}
}
}
}
Ok(results)
}
/// Fetch CI (GitHub Actions / checks) status for a list of pending reviews
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 mut results = Vec::new();
// Group by repo to fetch combined status per repo
use std::collections::HashMap;
let mut by_repo: HashMap<String, Vec<&PendingReview>> = HashMap::new();
for review in reviews {
by_repo.entry(review.repo.clone())
.or_insert_with(Vec::new)
.push(review);
}
for (repo, reviews_in_repo) in by_repo {
// Get the head SHAs for all PRs in this repo
for review in reviews_in_repo {
match client.pulls(org, &repo).get(review.pr_number).await {
Ok(pr) => {
let head_sha = pr.head.sha.clone();
let pr_title = pr.title.clone().unwrap_or_default();
// Fetch combined status for this commit via GitHub status API
#[derive(serde::Deserialize)]
struct CombinedStatus {
state: String,
}
let combined_status_url = format!(
"/repos/{}/{}/commits/{}/status",
org, repo, head_sha
);
let overall_status: String = client
.get(&combined_status_url, None::<&str>)
.await
.map(|s: CombinedStatus| s.state)
.unwrap_or_else(|_| "unknown".to_string());
// Fetch check runs (GitHub Actions, etc.) via check-runs endpoint
#[derive(serde::Deserialize)]
struct CheckRunsResponse {
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 check_runs_url = format!(
"/repos/{}/{}/commits/{}/check-runs",
org, repo, head_sha
);
let checks: Vec<CiCheck> = client
.get(&check_runs_url, None::<&str>)
.await
.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();
results.push(CiStatus {
repo: repo.clone(),
pr_number: review.pr_number,
pr_title,
head_sha,
overall_status,
checks,
});
}
Err(e) => {
eprintln!("Warning: Failed to fetch PR #{} in {}: {}", review.pr_number, repo, e);
}
}
}
}
Ok(results)
}
/// Represents a GitHub notification/mention for the current user.
#[derive(Debug, Clone, serde::Serialize)]
pub struct Mention {
/// Repository name (e.g. "org/repo")
pub repo: String,
/// PR or issue number
pub pr_number: u64,
/// PR or issue title
pub pr_title: String,
/// URL to the PR/issue
pub pr_url: String,
/// Whether this notification is unread
pub unread: bool,
/// Why the user is receiving this notification (mention, review_requested, etc.)
pub reason: String,
/// When the notification was last updated
pub updated_at: chrono::DateTime<chrono::Utc>,
/// Preview of the last comment that triggered the mention (if available)
pub last_comment_preview: String,
}
/// Fetch GitHub notifications where the user was mentioned or directly involved.
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()?;
// Use GitHub's notifications API
#[derive(serde::Deserialize)]
struct Notification {
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", "50")]))
.await
.unwrap_or_default();
let mut mentions = Vec::new();
for notification in all_notifications {
// Filter: only PR/issue notifications (not repository, discussions, etc.)
if notification.subject.notification_type != "PullRequest"
&& notification.subject.notification_type != "Issue" {
continue;
}
// Filter: only if it involves this user (mention, review_requested, assign, author, team_mention)
let relevant_reasons = ["mention", "review_requested", "assign", "author", "team_mention", "cm"];
if !relevant_reasons.contains(¬ification.reason.as_str()) {
continue;
}
// Filter: unread only
if unread_only && !notification.unread {
continue;
}
// Parse the PR number from the subject URL
// URL format: https://api.github.com/repos/{org}/{repo}/pulls/{number}
let pr_number: u64 = notification
.subject
.url
.as_ref()
.and_then(|url| {
url.split('/')
.last()
.and_then(|s| s.parse().ok())
})
.unwrap_or(0);
// Convert API URL to web URL
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
)
});
// Parse updated_at
let updated_at = chrono::DateTime::parse_from_rfc3339(¬ification.updated_at)
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(|_| chrono::Utc::now());
mentions.push(Mention {
repo: notification.repository.full_name,
pr_number,
pr_title: notification.subject.title,
pr_url,
unread: notification.unread,
reason: notification.reason,
updated_at,
last_comment_preview: String::new(),
});
if mentions.len() >= limit {
break;
}
}
// Sort by most recently updated
mentions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(mentions)
}
/// Represents GitHub API rate limit information.
#[derive(Debug, Clone, serde::Serialize)]
pub struct RateLimitInfo {
/// Resource type (e.g., "core", "search", "graphql")
pub resource: String,
/// Current limit
pub limit: u32,
/// Remaining requests in current window
pub remaining: u32,
/// Reset timestamp
pub reset: chrono::DateTime<chrono::Utc>,
/// Seconds until reset
pub reset_in_seconds: i64,
}
/// Represents overall GitHub API health status.
#[derive(Debug, Clone, serde::Serialize)]
pub struct HealthStatus {
/// API rate limits
pub rate_limits: Vec<RateLimitInfo>,
/// GitHub API server current time
pub server_time: chrono::DateTime<chrono::Utc>,
/// Whether the authenticated user has a valid token
pub authenticated: bool,
/// GitHub username (if authenticated)
pub username: Option<String>,
/// Whether the rate limit is being hit (remaining < 10% of limit)
pub rate_limit_warning: bool,
}
/// Fetch GitHub API rate limits and health status.
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 {
#[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,
#[serde(rename = "used")]
used: u32,
#[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![];
// Core API
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(),
});
// Search API
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(),
});
// GraphQL (optional)
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();
// Try to get authenticated user info
#[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();
// Check if any rate limit is critically low (< 10%)
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,
})
}
/// Represents a single event in a PR's timeline.
#[derive(Debug, Clone, serde::Serialize)]
pub struct TimelineEvent {
/// Event type: "PullRequestReview", "Comment", "LabelEvent", "AssignEvent", "MilestoneEvent", "ClosedEvent", "ReopenedEvent", "MergedEvent", etc.
pub event: String,
/// When this event occurred
pub created_at: chrono::DateTime<chrono::Utc>,
/// Actor (user) who triggered this event
pub actor: Option<String>,
/// Event-specific data (e.g., review state, comment body preview)
pub data: serde_json::Value,
}
/// Fetch the timeline of events for a specific PR.
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);
// Extract event-specific data
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();
}
}
_ => {
// For other event types, include relevant fields
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 API returns events newest-first, but we want chronological order
timeline.reverse();
Ok(timeline)
}
/// Send an emoji reaction to a PR/issue to get author's attention without leaving a comment.
///
/// GitHub supports these reaction emojis:
/// - `+1` (thumbs up)
/// - `-1` (thumbs down)
/// - `laugh` (😂)
/// - `confused` (😕)
/// - `heart` (❤️)
/// - `hooray` (🎉)
/// - `rocket` (🚀)
/// - `eyes` (👀)
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()?;
// Map friendly names to GitHub content values
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, // Use as-is if it doesn't match
};
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() })) // Uses POST /repos/{owner}/{repo}/issues/{issue_number}/reactions
.await?;
Ok(())
}