Files
novem/src/api/issues.rs

739 lines
25 KiB
Rust
Raw Normal View History

2026-05-06 01:42:38 +08:00
use std::ops::Deref;
2026-05-11 00:32:12 +08:00
use graphql_client::GraphQLQuery;
2026-05-06 01:42:38 +08:00
use serde::Deserialize;
use crate::{
2026-05-08 02:23:28 +08:00
api::{
self,
2026-05-11 00:32:12 +08:00
issues::{
pull_request_pagination_query::PullRequestPaginationQuerySearchEdgesNode,
pull_request_query::PullRequestQueryNode,
},
2026-05-08 02:23:28 +08:00
},
2026-05-06 01:42:38 +08:00
query,
};
2026-05-11 00:32:12 +08:00
type DateTime = String;
type URI = String;
2026-05-08 02:23:28 +08:00
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize)]
2026-05-06 01:42:38 +08:00
#[serde(transparent)]
#[repr(transparent)]
2026-05-08 02:23:28 +08:00
pub(crate) struct Id(String);
2026-05-06 01:42:38 +08:00
2026-05-08 02:23:28 +08:00
impl Deref for Id {
type Target = String;
2026-05-06 01:42:38 +08:00
2026-05-08 02:23:28 +08:00
fn deref(&self) -> &Self::Target {
&self.0
}
2026-05-06 01:42:38 +08:00
}
2026-05-11 00:32:12 +08:00
impl From<&str> for Id {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
impl From<String> for Id {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<Id> for String {
fn from(value: Id) -> Self {
value.0
}
}
2026-05-06 01:42:38 +08:00
#[derive(Debug, Deserialize)]
2026-05-08 02:23:28 +08:00
pub(crate) struct PullRequestPaginatedResponse {
pub(crate) items: Vec<PullRequest>,
pub(crate) start_cursor: Option<String>,
pub(crate) end_cursor: Option<String>,
2026-05-06 01:42:38 +08:00
}
#[derive(Debug, Deserialize)]
pub(crate) struct PullRequest {
2026-05-11 00:32:12 +08:00
pub(crate) id: Id,
2026-05-08 02:23:28 +08:00
pub(crate) title: String,
2026-05-11 00:32:12 +08:00
pub(crate) state: PullRequestState,
2026-05-08 02:23:28 +08:00
pub(crate) is_draft: bool,
pub(crate) repo_slug: String,
2026-05-06 01:42:38 +08:00
}
2026-05-11 00:32:12 +08:00
#[derive(Debug, Deserialize)]
pub(crate) struct DetailedPullRequest {
pub(crate) title: String,
pub(crate) state: PullRequestState,
pub(crate) is_draft: bool,
pub(crate) body: String,
2026-05-12 01:34:33 +08:00
pub(crate) author: Option<super::user::Actor>,
pub(crate) base_branch_name: Option<String>,
pub(crate) head_branch_name: Option<String>,
2026-05-11 00:32:12 +08:00
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PullRequestTimeline {
pub(crate) items: Vec<PullRequestTimelineItem>,
pub(crate) end_cursor: Option<String>,
pub(crate) has_next_page: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PullRequestTimelineItem {
Assigned {
created_at: String,
actor: Option<TimelineActor>,
assignee: Option<TimelineActor>,
},
Unassigned {
created_at: String,
actor: Option<TimelineActor>,
assignee: Option<TimelineActor>,
},
Comment {
created_at: String,
author: Option<TimelineActor>,
body: String,
},
Commit {
committed_at: String,
abbreviated_oid: String,
message_headline: String,
},
Review {
created_at: String,
author: Option<TimelineActor>,
state: String,
body: String,
},
ReviewRequested {
created_at: String,
actor: Option<TimelineActor>,
reviewer: Option<TimelineActor>,
},
ReviewRequestRemoved {
created_at: String,
actor: Option<TimelineActor>,
reviewer: Option<TimelineActor>,
},
ReviewDismissed {
created_at: String,
actor: Option<TimelineActor>,
},
Merged {
created_at: String,
actor: Option<TimelineActor>,
},
Closed {
created_at: String,
actor: Option<TimelineActor>,
},
Reopened {
created_at: String,
actor: Option<TimelineActor>,
},
ConvertToDraft {
created_at: String,
actor: Option<TimelineActor>,
},
ReadyForReview {
created_at: String,
actor: Option<TimelineActor>,
},
HeadRefForcePushed {
created_at: String,
actor: Option<TimelineActor>,
before_commit_oid: Option<String>,
after_commit_oid: Option<String>,
},
BaseRefChanged {
created_at: String,
actor: Option<TimelineActor>,
},
Labeled {
created_at: String,
actor: Option<TimelineActor>,
label: String,
},
Unlabeled {
created_at: String,
actor: Option<TimelineActor>,
label: String,
},
Milestoned {
created_at: String,
actor: Option<TimelineActor>,
milestone_title: String,
},
Demilestoned {
created_at: String,
actor: Option<TimelineActor>,
milestone_title: String,
},
Referenced {
created_at: String,
actor: Option<TimelineActor>,
},
CrossReferenced {
created_at: String,
actor: Option<TimelineActor>,
},
AutoMergeEnabled {
created_at: String,
actor: Option<TimelineActor>,
},
AutoMergeDisabled {
created_at: String,
actor: Option<TimelineActor>,
reason: String,
},
AddedToMergeQueue {
created_at: String,
actor: Option<TimelineActor>,
},
RemovedFromMergeQueue {
created_at: String,
actor: Option<TimelineActor>,
},
Other {
typename: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TimelineActor {
pub(crate) kind: String,
pub(crate) name: String,
pub(crate) avatar_url: Option<String>,
2026-05-06 01:42:38 +08:00
}
impl std::fmt::Display for Id {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
2026-05-11 00:32:12 +08:00
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum PullRequestState {
Open,
Closed,
Merged,
2026-05-06 01:42:38 +08:00
}
2026-05-08 02:23:28 +08:00
#[derive(graphql_client::GraphQLQuery)]
#[graphql(
schema_path = "src/api/graphql/schema.json",
query_path = "src/api/graphql/list_pull_requests.graphql"
2026-05-11 00:32:12 +08:00
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")
2026-05-08 02:23:28 +08:00
)]
struct PullRequestQuery;
2026-05-11 00:32:12 +08:00
#[derive(graphql_client::GraphQLQuery)]
#[graphql(
schema_path = "src/api/graphql/schema.json",
query_path = "src/api/graphql/fetch_pull_request_timeline.graphql"
)]
struct PullRequestTimelineQuery;
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,
};
2026-05-06 01:42:38 +08:00
#[derive(Clone)]
pub(crate) struct ListPullRequests {
pub filter: Option<&'static str>,
pub page: u32,
}
impl query::QueryFn for ListPullRequests {
2026-05-08 02:23:28 +08:00
type Data = PullRequestPaginatedResponse;
2026-05-06 01:42:38 +08:00
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<Self::Data, Self::Error> {
#[cfg(debug_assertions)]
if c.should_use_fixtures {
return super::mock::list_pull_requests(self.filter, self.page);
}
2026-05-08 02:23:28 +08:00
let query_string = match self.filter {
2026-05-12 01:34:33 +08:00
| Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter),
| None => "is:pr archived:false sort:updated-desc".into(),
2026-05-08 02:23:28 +08:00
};
2026-05-11 00:32:12 +08:00
let gql =
PullRequestPaginationQuery::build_query(pull_request_pagination_query::Variables {
query: query_string,
});
2026-05-06 01:42:38 +08:00
2026-05-11 00:32:12 +08:00
let res = c.github_graphql_request(&gql)?.send().await?;
2026-05-06 01:42:38 +08:00
2026-05-11 00:32:12 +08:00
let (_, data) =
api::parse_graphql_response::<pull_request_pagination_query::ResponseData>(res).await?;
2026-05-08 02:23:28 +08:00
Ok(PullRequestPaginatedResponse {
items: data
.search
.edges
.map(|it| {
it.into_iter()
.flatten()
.filter_map(|edge| {
edge.node.and_then(|n| match n {
2026-05-12 01:34:33 +08:00
| PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => {
Some(PullRequest {
id: p.id.into(),
title: p.title,
state: p.state,
is_draft: p.is_draft,
repo_slug: format!(
"{}/{}",
p.repository.owner.login, p.repository.name
),
})
}
| _ => None,
2026-05-08 02:23:28 +08:00
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default(),
start_cursor: data.search.page_info.start_cursor,
end_cursor: data.search.page_info.end_cursor,
})
2026-05-06 01:42:38 +08:00
}
}
2026-05-11 00:32:12 +08:00
#[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<Self::Data, Self::Error> {
#[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.clone().into(),
});
let res = c.github_graphql_request(&gql)?.send().await?;
let (_, data) =
api::parse_graphql_response::<pull_request_query::ResponseData>(res).await?;
data.node
.ok_or(api::Error::MalformedResponse(
"missing 'node' field on PullRequestQuery response".into(),
))
.and_then(|n| match n {
2026-05-12 01:34:33 +08:00
| PullRequestQueryNode::PullRequest(p) => Ok(DetailedPullRequest {
title: p.title,
state: p.state,
is_draft: p.is_draft,
body: p.body,
author: p.author.map(|it| api::user::Actor {
login: it.login,
avatar_url: it.avatar_url,
2026-05-11 00:32:12 +08:00
}),
2026-05-12 01:34:33 +08:00
base_branch_name: p.base_ref.map(|r| r.name),
head_branch_name: p.head_ref.map(|r| r.name),
}),
| _ => Err(api::Error::MalformedResponse(
"unexpected node type on PullRequestQuery".into(),
)),
2026-05-11 00:32:12 +08:00
})
}
}
#[derive(Clone)]
pub(crate) struct FetchPullRequestTimeline {
pub(crate) id: Id,
pub(crate) first: i64,
pub(crate) after: Option<String>,
}
impl FetchPullRequestTimeline {
pub(crate) fn new(id: Id, first: i64, after: Option<String>) -> Self {
Self { id, first, after }
}
}
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<Self::Data, Self::Error> {
fn normalize_actor(actor: actorFields) -> TimelineActor {
let actorFields {
login,
avatar_url,
on,
} = actor;
TimelineActor {
kind: match on {
2026-05-12 01:34:33 +08:00
| actorFieldsOn::Bot => "Bot",
| actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount",
| actorFieldsOn::Mannequin => "Mannequin",
| actorFieldsOn::Organization => "Organization",
| actorFieldsOn::User => "User",
2026-05-11 00:32:12 +08:00
}
.into(),
name: login,
avatar_url: Some(avatar_url),
}
}
fn normalize_assignee(actor: assigneeFields) -> TimelineActor {
match actor {
2026-05-12 01:34:33 +08:00
| 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),
},
2026-05-11 00:32:12 +08:00
}
}
fn normalize_requested_reviewer(actor: requestedReviewerFields) -> TimelineActor {
match actor {
2026-05-12 01:34:33 +08:00
| 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),
},
2026-05-11 00:32:12 +08:00
}
}
fn normalize_review_state(state: PullRequestReviewState) -> String {
match state {
2026-05-12 01:34:33 +08:00
| PullRequestReviewState::PENDING => "PENDING",
| PullRequestReviewState::COMMENTED => "COMMENTED",
| PullRequestReviewState::APPROVED => "APPROVED",
| PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED",
| PullRequestReviewState::DISMISSED => "DISMISSED",
| _ => "OTHER",
2026-05-11 00:32:12 +08:00
}
.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.clone().into(),
first: self.first,
after: self.after.clone(),
});
let res = c.github_graphql_request(&gql)?.send().await?;
api::parse_graphql_response::<PullRequestTimelineResponse>(res)
.await?
.1
};
#[cfg(not(debug_assertions))]
let data = {
let gql =
PullRequestTimelineQuery::build_query(pull_request_timeline_query::Variables {
id: self.id.clone().into(),
first: self.first,
after: self.after.clone(),
});
let res = c.github_graphql_request(&gql)?.send().await?;
api::parse_graphql_response::<PullRequestTimelineResponse>(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 {
2026-05-12 01:34:33 +08:00
| PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request),
| _ => Err(api::Error::MalformedResponse(
"unexpected node type on PullRequestTimelineQuery".into(),
)),
2026-05-11 00:32:12 +08:00
})?;
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,
has_next_page: timeline.page_info.has_next_page,
})
}
}