use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::error::Result;
use crate::forge::remote_comments::RemoteReviewThread;
use crate::forge::submit::SubmitEvent;
use crate::model::{DiffLine, FileStatus};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ForgeKind {
GitHub,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForgeRepository {
pub kind: ForgeKind,
pub host: String,
pub owner: String,
pub name: String,
}
impl ForgeRepository {
pub fn github(
host: impl Into<String>,
owner: impl Into<String>,
name: impl Into<String>,
) -> Self {
Self {
kind: ForgeKind::GitHub,
host: host.into(),
owner: owner.into(),
name: name.into(),
}
}
pub fn slug(&self) -> String {
format!("{}/{}", self.owner, self.name)
}
pub fn display_name(&self) -> String {
if self.host == "github.com" {
self.slug()
} else {
format!("{}/{}", self.host, self.slug())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PullRequestTarget {
pub repository: Option<ForgeRepository>,
pub number: u64,
pub original: String,
}
impl PullRequestTarget {
pub fn number(number: u64, original: impl Into<String>) -> Self {
Self {
repository: None,
number,
original: original.into(),
}
}
pub fn with_repository(
repository: ForgeRepository,
number: u64,
original: impl Into<String>,
) -> Self {
Self {
repository: Some(repository),
number,
original: original.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullRequestListQuery {
pub repository: ForgeRepository,
pub already_loaded: usize,
pub page_size: usize,
}
impl PullRequestListQuery {
pub fn first_page(repository: ForgeRepository, page_size: usize) -> Self {
Self {
repository,
already_loaded: 0,
page_size,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PullRequestSummary {
pub repository: ForgeRepository,
pub number: u64,
pub title: String,
pub author: Option<String>,
pub head_ref_name: String,
pub base_ref_name: String,
pub updated_at: Option<DateTime<Utc>>,
pub url: String,
pub state: String,
pub is_draft: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PagedPullRequests {
pub pull_requests: Vec<PullRequestSummary>,
pub has_more: bool,
pub total_loaded: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PullRequestDetails {
pub repository: ForgeRepository,
pub number: u64,
pub title: String,
pub url: String,
pub state: String,
pub is_draft: bool,
pub author: Option<String>,
pub head_ref_name: String,
pub base_ref_name: String,
pub head_sha: String,
pub base_sha: String,
pub body: String,
pub updated_at: Option<DateTime<Utc>>,
pub closed: bool,
pub merged_at: Option<DateTime<Utc>>,
}
impl PullRequestDetails {
pub fn is_read_only(&self) -> bool {
self.closed || self.merged_at.is_some()
}
pub fn read_only_reason(&self) -> Option<&'static str> {
if self.merged_at.is_some() {
Some("merged")
} else if self.closed {
Some("closed")
} else {
None
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrSessionKey {
pub repository: ForgeRepository,
pub number: u64,
pub head_sha: String,
}
impl PrSessionKey {
pub fn new(repository: ForgeRepository, number: u64, head_sha: impl Into<String>) -> Self {
Self {
repository,
number,
head_sha: head_sha.into(),
}
}
pub fn from_details(details: &PullRequestDetails) -> Self {
Self::new(
details.repository.clone(),
details.number,
details.head_sha.clone(),
)
}
pub fn short_head(&self) -> String {
self.head_sha
.chars()
.take(8.min(self.head_sha.len()))
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ForgeFileSide {
Base,
Head,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForgeFileLinesRequest {
pub repository: ForgeRepository,
pub base_sha: String,
pub head_sha: String,
pub path: PathBuf,
pub status: FileStatus,
pub side: ForgeFileSide,
pub start_line: u32,
pub end_line: u32,
}
impl ForgeFileLinesRequest {
pub fn side_for_status(status: FileStatus) -> ForgeFileSide {
match status {
FileStatus::Deleted => ForgeFileSide::Base,
FileStatus::Added | FileStatus::Modified | FileStatus::Renamed | FileStatus::Copied => {
ForgeFileSide::Head
}
}
}
pub fn path_for_side(
side: ForgeFileSide,
old_path: Option<&PathBuf>,
new_path: Option<&PathBuf>,
) -> Option<PathBuf> {
match side {
ForgeFileSide::Base => old_path.or(new_path).cloned(),
ForgeFileSide::Head => new_path.or(old_path).cloned(),
}
}
pub fn sha(&self) -> &str {
match self.side {
ForgeFileSide::Base => &self.base_sha,
ForgeFileSide::Head => &self.head_sha,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GhCreateReviewResponse {
pub id: u64,
pub html_url: String,
pub state: String,
}
#[derive(Debug, Clone)]
pub struct CreateReviewRequest<'a> {
pub event: SubmitEvent,
pub commit_id: &'a str,
pub body: &'a str,
pub comments: &'a [crate::forge::submit::InlineComment],
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PullRequestCommit {
pub oid: String,
pub short_oid: String,
pub summary: String,
pub author: String,
pub timestamp: Option<DateTime<Utc>>,
}
pub trait ForgeBackend {
fn list_pull_requests(&self, query: PullRequestListQuery) -> Result<PagedPullRequests>;
fn get_pull_request(&self, target: PullRequestTarget) -> Result<PullRequestDetails>;
fn get_pull_request_diff(&self, pr: &PullRequestDetails) -> Result<String>;
fn fetch_file_lines(&self, request: ForgeFileLinesRequest) -> Result<Vec<DiffLine>>;
fn list_review_threads(&self, pr: &PullRequestDetails) -> Result<Vec<RemoteReviewThread>>;
fn list_pull_request_commits(&self, pr: &PullRequestDetails) -> Result<Vec<PullRequestCommit>>;
fn get_pull_request_commit_range_diff(
&self,
pr: &PullRequestDetails,
start_sha: &str,
end_sha: &str,
) -> Result<String>;
fn local_checkout_path(&self) -> Option<PathBuf> {
None
}
fn create_review(
&self,
pr: &PullRequestDetails,
request: CreateReviewRequest<'_>,
) -> Result<GhCreateReviewResponse>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_round_trip_pr_session_key_via_serde() {
let key = PrSessionKey::new(
ForgeRepository::github("github.com", "agavra", "tuicr"),
125,
"abcdef0123456789".to_string(),
);
let serialized = serde_json::to_string(&key).unwrap();
let restored: PrSessionKey = serde_json::from_str(&serialized).unwrap();
assert_eq!(key, restored);
}
#[test]
fn should_truncate_long_head_sha_for_short_head() {
let key = PrSessionKey::new(
ForgeRepository::github("github.com", "a", "b"),
1,
"1234567890abcdef1234567890abcdef".to_string(),
);
assert_eq!(key.short_head(), "12345678");
}
#[test]
fn should_handle_short_head_sha_gracefully() {
let key = PrSessionKey::new(
ForgeRepository::github("github.com", "a", "b"),
1,
"abc".to_string(),
);
assert_eq!(key.short_head(), "abc");
}
#[test]
fn should_pick_head_side_for_added_modified_renamed_copied() {
for status in [
FileStatus::Added,
FileStatus::Modified,
FileStatus::Renamed,
FileStatus::Copied,
] {
assert_eq!(
ForgeFileLinesRequest::side_for_status(status),
ForgeFileSide::Head,
"{status:?} should pick head"
);
}
}
#[test]
fn should_pick_base_side_for_deleted_files() {
assert_eq!(
ForgeFileLinesRequest::side_for_status(FileStatus::Deleted),
ForgeFileSide::Base,
);
}
}