use std::sync::Arc; use graphql_client::GraphQLQuery; use serde::Deserialize; use crate::{ api::{ self, issues::{ pull_request_pagination_query::PullRequestPaginationQuerySearchEdgesNode, pull_request_query::PullRequestQueryNode, }, }, query, util, }; type DateTime = String; type URI = String; type GitObjectID = String; #[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize)] #[serde(transparent)] #[repr(transparent)] pub(crate) struct Id(pub(crate) Arc); #[derive(Debug, Deserialize)] pub(crate) struct PullRequestPaginatedResponse { pub(crate) items: Vec, pub(crate) start_cursor: Option, pub(crate) end_cursor: Option, } #[derive(Debug, Deserialize)] pub(crate) struct PullRequest { pub(crate) id: Id, pub(crate) title: Arc, pub(crate) state: PullRequestState, pub(crate) is_draft: bool, pub(crate) repo_slug: Arc, } #[derive(Debug, Deserialize)] pub(crate) struct DetailedPullRequest { pub(crate) id: Id, pub(crate) title: Arc, pub(crate) state: PullRequestState, pub(crate) is_draft: bool, pub(crate) body: Arc, pub(crate) created_at: Option>, pub(crate) author: Option, pub(crate) base_branch_name: Arc, pub(crate) base_repo_slug: Arc, pub(crate) base_ref: Arc, pub(crate) head_branch_name: Arc, pub(crate) head_ref: Arc, pub(crate) head_repo_slug: Arc, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PullRequestTimeline { pub(crate) items: Vec, pub(crate) end_cursor: Option>, pub(crate) has_next_page: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum PullRequestTimelineItem { Assigned { created_at: String, actor: Option, assignee: Option, }, Unassigned { created_at: String, actor: Option, assignee: Option, }, Comment { created_at: String, author: Option, body: String, }, Commit { committed_at: String, abbreviated_oid: String, message_headline: String, }, Review { created_at: String, author: Option, state: String, body: String, }, ReviewRequested { created_at: String, actor: Option, reviewer: Option, }, ReviewRequestRemoved { created_at: String, actor: Option, reviewer: Option, }, ReviewDismissed { created_at: String, actor: Option, }, Merged { created_at: String, actor: Option, }, Closed { created_at: String, actor: Option, }, Reopened { created_at: String, actor: Option, }, ConvertToDraft { created_at: String, actor: Option, }, ReadyForReview { created_at: String, actor: Option, }, HeadRefForcePushed { created_at: String, actor: Option, before_commit_oid: Option, after_commit_oid: Option, }, BaseRefChanged { created_at: String, actor: Option, }, Labeled { created_at: String, actor: Option, label: String, }, Unlabeled { created_at: String, actor: Option, label: String, }, Milestoned { created_at: String, actor: Option, milestone_title: String, }, Demilestoned { created_at: String, actor: Option, milestone_title: String, }, Referenced { created_at: String, actor: Option, }, CrossReferenced { created_at: String, actor: Option, }, AutoMergeEnabled { created_at: String, actor: Option, }, AutoMergeDisabled { created_at: String, actor: Option, reason: String, }, AddedToMergeQueue { created_at: String, actor: Option, }, RemovedFromMergeQueue { created_at: String, actor: Option, }, Other { typename: String, }, } pub(crate) struct ChangedFile { pub(crate) cursor: String, pub(crate) change_type: ChangeType, pub(crate) additions: i64, pub(crate) deletions: i64, pub(crate) path: Arc, pub(crate) viewer_viewed_state: FileViewedState, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub(crate) enum FileViewedState { Dismissed, Viewed, Unviewed, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub(crate) enum ChangeType { Added, Modified, Deleted, Renamed, Copied, Changed, } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TimelineActor { pub(crate) kind: String, pub(crate) name: String, pub(crate) avatar_url: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub(crate) enum PullRequestState { Open, Closed, Merged, } #[derive(graphql_client::GraphQLQuery)] #[graphql( schema_path = "src/api/graphql/schema.json", query_path = "src/api/graphql/list_pull_requests.graphql" extern_enums("PullRequestState") )] struct PullRequestPaginationQuery; #[derive(graphql_client::GraphQLQuery)] #[graphql( schema_path = "src/api/graphql/schema.json", query_path = "src/api/graphql/fetch_pull_request.graphql" extern_enums("PullRequestState") )] struct PullRequestQuery; #[derive(graphql_client::GraphQLQuery)] #[graphql( schema_path = "src/api/graphql/schema.json", query_path = "src/api/graphql/fetch_pull_request_timeline.graphql" )] struct PullRequestTimelineQuery; #[derive(graphql_client::GraphQLQuery)] #[graphql( schema_path = "src/api/graphql/schema.json", query_path = "src/api/graphql/fetch_pull_request_file_tree.graphql", extern_enums("FileViewedState") )] struct PullRequestFileTreeQuery; pub(super) type PullRequestFileTreeResponse = pull_request_file_tree_query::ResponseData; pub(super) type PullRequestTimelineResponse = pull_request_timeline_query::ResponseData; #[cfg(test)] pub(super) type PullRequestTimelineResponseNode = PullRequestTimelineQueryNode; #[cfg(test)] pub(super) type PullRequestTimelineNode = pull_request_timeline_query::PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes; use self::pull_request_timeline_query::{ PullRequestReviewState, PullRequestTimelineQueryNode, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes, actorFields, actorFieldsOn, assigneeFields, requestedReviewerFields, }; #[derive(Clone)] pub(crate) struct ListPullRequests { pub filter: Option<&'static str>, pub page: u32, } impl std::fmt::Display for Id { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } impl From for Id { fn from(value: String) -> Self { Self(value.into()) } } impl From<&str> for Id { fn from(value: &str) -> Self { Self(value.into()) } } impl query::QueryFn for ListPullRequests { type Data = PullRequestPaginatedResponse; type Error = api::Error; type Context = api::QueryContext; fn key(&self) -> query::Key { format!( "issues/list?pulls=true&page={}&filter={}", self.page, self.filter.unwrap_or_default() ) .into() } async fn run(&self, c: &Self::Context) -> Result { #[cfg(debug_assertions)] if c.should_use_fixtures { return super::mock::list_pull_requests(self.filter, self.page); } let query_string = match self.filter { | Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter), | None => "is:pr archived:false sort:updated-desc".into(), }; let gql = PullRequestPaginationQuery::build_query(pull_request_pagination_query::Variables { query: query_string, }); let res = c.github_graphql_request(&gql)?.send().await?; let (_, data) = api::parse_graphql_response::(res).await?; Ok(PullRequestPaginatedResponse { items: data .search .edges .map(|it| { it.into_iter() .flatten() .filter_map(|edge| { edge.node.and_then(|n| match n { | PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => { Some(PullRequest { id: p.id.into(), title: p.title.into(), state: p.state, is_draft: p.is_draft, repo_slug: format!( "{}/{}", p.repository.owner.login, p.repository.name ) .into(), }) } | _ => None, }) }) .collect::>() }) .unwrap_or_default(), start_cursor: data.search.page_info.start_cursor, end_cursor: data.search.page_info.end_cursor, }) } } #[derive(Clone)] pub(crate) struct FetchPullRequest { pub(crate) id: Id, } impl query::QueryFn for FetchPullRequest { type Data = DetailedPullRequest; type Error = api::Error; type Context = api::QueryContext; fn key(&self) -> query::Key { format!("issues/{}", self.id).into() } async fn run(&self, c: &Self::Context) -> Result { #[cfg(debug_assertions)] if c.should_use_fixtures { return super::mock::fetch_pull_request(&self.id); } let gql = PullRequestQuery::build_query(pull_request_query::Variables { id: self.id.to_string(), }); let res = c.github_graphql_request(&gql)?.send().await?; let (_, data) = api::parse_graphql_response::(res).await?; data.node .ok_or(api::Error::MalformedResponse( "missing 'node' field on PullRequestQuery response".into(), )) .and_then(|n| match n { | PullRequestQueryNode::PullRequest(p) => { let created_at = chrono::DateTime::parse_from_rfc3339(&p.created_at).map_err(|err| { api::Error::MalformedResponse(format!( "invalid pull request createdAt {:?}: {err}", p.created_at )) })?; Ok(DetailedPullRequest { id: Id(p.id.into()), title: p.title.into(), state: p.state, is_draft: p.is_draft, body: p.body.into(), author: p.author.map(|it| api::user::Actor { login: it.login.into(), avatar_url: it.avatar_url.into(), }), base_repo_slug: p .base_repository .map(|it| it.name_with_owner.into()) .unwrap_or_default(), base_branch_name: p.base_ref_name.into(), base_ref: p.base_ref_oid.into(), head_repo_slug: p .head_repository .map(|it| it.name_with_owner.into()) .unwrap_or_default(), head_branch_name: p.head_ref_name.into(), head_ref: p.head_ref_oid.into(), created_at: Some(created_at), }) } | _ => Err(api::Error::MalformedResponse( "unexpected node type on PullRequestQuery".into(), )), }) } } #[derive(Clone)] pub(crate) struct FetchPullRequestFileTree { pub(crate) id: Id, pub(crate) first: i64, } impl query::QueryFn for FetchPullRequestFileTree { type Data = util::file::SortedByPath; type Error = api::Error; type Context = api::QueryContext; fn key(&self) -> query::Key { format!("issues/{}/files?first={}", self.id, self.first).into() } async fn run(&self, c: &Self::Context) -> Result { #[cfg(debug_assertions)] let data = if c.should_use_fixtures { super::mock::fetch_pull_request_file_tree(&self.id)? } else { let gql = PullRequestFileTreeQuery::build_query(pull_request_file_tree_query::Variables { id: self.id.to_string(), first: self.first, }); let res = c.github_graphql_request(&gql)?.send().await?; api::parse_graphql_response::(res) .await? .1 }; #[cfg(not(debug_assertions))] let data = { let gql = PullRequestFileTreeQuery::build_query(pull_request_file_tree_query::Variables { id: self.id.to_string(), first: self.first, }); let res = c.github_graphql_request(&gql)?.send().await?; api::parse_graphql_response::(res) .await? .1 }; let pull_request = data .node .ok_or(api::Error::MalformedResponse( "missing 'node' field on PullRequestFileTreeQuery response".into(), )) .and_then(|node| match node { | pull_request_file_tree_query::PullRequestFileTreeQueryNode::PullRequest( pull_request, ) => Ok(pull_request), | _ => Err(api::Error::MalformedResponse( "unexpected node type on PullRequestFileTreeQuery".into(), )), })?; Ok(pull_request .files .and_then(|files| files.edges) .map(|it| { it.into_iter() .flatten() .filter_map(|edge| { let cursor = edge.cursor; edge.node.map(|node| ChangedFile { cursor, change_type: match node.change_type { | pull_request_file_tree_query::PatchStatus::ADDED => ChangeType::Added, | pull_request_file_tree_query::PatchStatus::MODIFIED => { ChangeType::Modified } | pull_request_file_tree_query::PatchStatus::DELETED => { ChangeType::Deleted } | pull_request_file_tree_query::PatchStatus::RENAMED => { ChangeType::Renamed } | pull_request_file_tree_query::PatchStatus::COPIED => { ChangeType::Copied } | pull_request_file_tree_query::PatchStatus::CHANGED => { ChangeType::Changed } | _ => ChangeType::Changed, }, additions: node.additions, deletions: node.deletions, path: node.path.into(), viewer_viewed_state: node.viewer_viewed_state, }) }) .collect::>() }) .map(|files| util::file::sort_by_path(files, |f| &f.path)) .unwrap_or_default()) } } #[derive(Clone)] pub(crate) struct FetchPullRequestTimeline { pub(crate) id: Id, pub(crate) first: i64, pub(crate) after: Option>, } impl query::QueryFn for FetchPullRequestTimeline { type Data = PullRequestTimeline; type Error = api::Error; type Context = api::QueryContext; fn key(&self) -> query::Key { format!( "issues/{}/timeline?first={}&after={}", self.id, self.first, self.after.as_deref().unwrap_or_default() ) .into() } async fn run(&self, c: &Self::Context) -> Result { fn normalize_actor(actor: actorFields) -> TimelineActor { let actorFields { login, avatar_url, on, } = actor; TimelineActor { kind: match on { | actorFieldsOn::Bot => "Bot", | actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount", | actorFieldsOn::Mannequin => "Mannequin", | actorFieldsOn::Organization => "Organization", | actorFieldsOn::User => "User", } .into(), name: login, avatar_url: Some(avatar_url), } } fn normalize_assignee(actor: assigneeFields) -> TimelineActor { match actor { | assigneeFields::Bot(actor) => TimelineActor { kind: "Bot".into(), name: actor.login, avatar_url: Some(actor.avatar_url), }, | assigneeFields::Mannequin(actor) => TimelineActor { kind: "Mannequin".into(), name: actor.login, avatar_url: Some(actor.avatar_url), }, | assigneeFields::Organization(actor) => TimelineActor { kind: "Organization".into(), name: actor.login, avatar_url: Some(actor.avatar_url), }, | assigneeFields::User(actor) => TimelineActor { kind: "User".into(), name: actor.login, avatar_url: Some(actor.avatar_url), }, } } fn normalize_requested_reviewer(actor: requestedReviewerFields) -> TimelineActor { match actor { | requestedReviewerFields::Bot(actor) => TimelineActor { kind: "Bot".into(), name: actor.login, avatar_url: Some(actor.avatar_url), }, | requestedReviewerFields::Mannequin(actor) => TimelineActor { kind: "Mannequin".into(), name: actor.login, avatar_url: Some(actor.avatar_url), }, | requestedReviewerFields::Team(actor) => TimelineActor { kind: "Team".into(), name: actor.name, avatar_url: None, }, | requestedReviewerFields::User(actor) => TimelineActor { kind: "User".into(), name: actor.login, avatar_url: Some(actor.avatar_url), }, } } fn normalize_review_state(state: PullRequestReviewState) -> String { match state { | PullRequestReviewState::PENDING => "PENDING", | PullRequestReviewState::COMMENTED => "COMMENTED", | PullRequestReviewState::APPROVED => "APPROVED", | PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED", | PullRequestReviewState::DISMISSED => "DISMISSED", | _ => "OTHER", } .into() } fn normalize_item( value: PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes, ) -> PullRequestTimelineItem { match value { PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::AssignedEvent( event, ) => PullRequestTimelineItem::Assigned { created_at: event.created_at, actor: event.actor.map(normalize_actor), assignee: event.assignee.map(normalize_assignee), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::UnassignedEvent( event, ) => PullRequestTimelineItem::Unassigned { created_at: event.created_at, actor: event.actor.map(normalize_actor), assignee: event.assignee.map(normalize_assignee), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::IssueComment( event, ) => PullRequestTimelineItem::Comment { created_at: event.created_at, author: event.author.map(normalize_actor), body: event.body, }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::PullRequestCommit( event, ) => PullRequestTimelineItem::Commit { committed_at: event.commit.committed_date, abbreviated_oid: event.commit.abbreviated_oid, message_headline: event.commit.message_headline, }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::PullRequestReview( event, ) => PullRequestTimelineItem::Review { created_at: event.created_at, author: event.author.map(normalize_actor), state: normalize_review_state(event.state), body: event.body, }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReviewRequestedEvent( event, ) => PullRequestTimelineItem::ReviewRequested { created_at: event.created_at, actor: event.actor.map(normalize_actor), reviewer: event.requested_reviewer.map(normalize_requested_reviewer), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReviewRequestRemovedEvent( event, ) => PullRequestTimelineItem::ReviewRequestRemoved { created_at: event.created_at, actor: event.actor.map(normalize_actor), reviewer: event.requested_reviewer.map(normalize_requested_reviewer), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReviewDismissedEvent( event, ) => PullRequestTimelineItem::ReviewDismissed { created_at: event.created_at, actor: event.actor.map(normalize_actor), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::MergedEvent( event, ) => PullRequestTimelineItem::Merged { created_at: event.created_at, actor: event.actor.map(normalize_actor), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ClosedEvent( event, ) => PullRequestTimelineItem::Closed { created_at: event.created_at, actor: event.actor.map(normalize_actor), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReopenedEvent( event, ) => PullRequestTimelineItem::Reopened { created_at: event.created_at, actor: event.actor.map(normalize_actor), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ConvertToDraftEvent( event, ) => PullRequestTimelineItem::ConvertToDraft { created_at: event.created_at, actor: event.actor.map(normalize_actor), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReadyForReviewEvent( event, ) => PullRequestTimelineItem::ReadyForReview { created_at: event.created_at, actor: event.actor.map(normalize_actor), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::HeadRefForcePushedEvent( event, ) => PullRequestTimelineItem::HeadRefForcePushed { created_at: event.created_at, actor: event.actor.map(normalize_actor), before_commit_oid: event.before_commit.map(|commit| commit.abbreviated_oid), after_commit_oid: event.after_commit.map(|commit| commit.abbreviated_oid), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::BaseRefChangedEvent( event, ) => PullRequestTimelineItem::BaseRefChanged { created_at: event.created_at, actor: event.actor.map(normalize_actor), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::LabeledEvent( event, ) => PullRequestTimelineItem::Labeled { created_at: event.created_at, actor: event.actor.map(normalize_actor), label: event.label.name, }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::UnlabeledEvent( event, ) => PullRequestTimelineItem::Unlabeled { created_at: event.created_at, actor: event.actor.map(normalize_actor), label: event.label.name, }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::MilestonedEvent( event, ) => PullRequestTimelineItem::Milestoned { created_at: event.created_at, actor: event.actor.map(normalize_actor), milestone_title: event.milestone_title, }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::DemilestonedEvent( event, ) => PullRequestTimelineItem::Demilestoned { created_at: event.created_at, actor: event.actor.map(normalize_actor), milestone_title: event.milestone_title, }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::ReferencedEvent( event, ) => PullRequestTimelineItem::Referenced { created_at: event.created_at, actor: event.actor.map(normalize_actor), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::CrossReferencedEvent( event, ) => PullRequestTimelineItem::CrossReferenced { created_at: event.created_at, actor: event.actor.map(normalize_actor), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::AutoMergeEnabledEvent( event, ) => PullRequestTimelineItem::AutoMergeEnabled { created_at: event.created_at, actor: event.actor.map(normalize_actor), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::AutoMergeDisabledEvent( event, ) => PullRequestTimelineItem::AutoMergeDisabled { created_at: event.created_at, actor: event.actor.map(normalize_actor), reason: event.reason.unwrap_or_default(), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::AddedToMergeQueueEvent( event, ) => PullRequestTimelineItem::AddedToMergeQueue { created_at: event.created_at, actor: event.actor.map(normalize_actor), }, PullRequestTimelineQueryNodeOnPullRequestTimelineItemsNodes::RemovedFromMergeQueueEvent( event, ) => PullRequestTimelineItem::RemovedFromMergeQueue { created_at: event.created_at, actor: event.actor.map(normalize_actor), }, _ => PullRequestTimelineItem::Other { typename: "Other".into(), }, } } #[cfg(debug_assertions)] let data = if c.should_use_fixtures { super::mock::fetch_pull_request_timeline(&self.id, self.after.as_deref())? } else { let gql = PullRequestTimelineQuery::build_query(pull_request_timeline_query::Variables { id: self.id.to_string(), first: self.first, after: self.after.as_ref().map(|it| it.to_string()), }); let res = c.github_graphql_request(&gql)?.send().await?; api::parse_graphql_response::(res) .await? .1 }; #[cfg(not(debug_assertions))] let data = { let gql = PullRequestTimelineQuery::build_query(pull_request_timeline_query::Variables { id: self.id.to_string(), first: self.first, after: self.after.as_ref().map(|it| it.to_string()), }); let res = c.github_graphql_request(&gql)?.send().await?; api::parse_graphql_response::(res) .await? .1 }; let pull_request = data .node .ok_or(api::Error::MalformedResponse( "missing 'node' field on PullRequestTimelineQuery response".into(), )) .and_then(|node| match node { | PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request), | _ => Err(api::Error::MalformedResponse( "unexpected node type on PullRequestTimelineQuery".into(), )), })?; let timeline = pull_request.timeline_items; let items = timeline .nodes .unwrap_or_default() .into_iter() .flatten() .map(normalize_item) .collect(); Ok(PullRequestTimeline { items, end_cursor: timeline.page_info.end_cursor.map(|it| it.into()), has_next_page: timeline.page_info.has_next_page, }) } }