wip: pr file diffing

This commit is contained in:
2026-05-18 22:30:46 +08:00
parent aa99ba2596
commit 553af0290f
32 changed files with 1523 additions and 164 deletions

View File

@@ -16,6 +16,7 @@ use crate::{
type DateTime = String;
type URI = String;
type GitObjectID = String;
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize)]
#[serde(transparent)]
@@ -72,8 +73,12 @@ pub(crate) struct DetailedPullRequest {
pub(crate) body: String,
pub(crate) created_at: Option<chrono::DateTime<chrono::FixedOffset>>,
pub(crate) author: Option<super::user::Actor>,
pub(crate) base_branch_name: Option<String>,
pub(crate) head_branch_name: Option<String>,
pub(crate) base_branch_name: String,
pub(crate) base_repo_slug: String,
pub(crate) base_ref: String,
pub(crate) head_branch_name: String,
pub(crate) head_ref: String,
pub(crate) head_repo_slug: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -205,6 +210,34 @@ pub(crate) enum PullRequestTimelineItem {
},
}
pub(crate) struct ChangedFile {
pub(crate) cursor: String,
pub(crate) change_type: ChangeType,
pub(crate) additions: i64,
pub(crate) deletions: i64,
pub(crate) path: String,
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,
@@ -249,6 +282,15 @@ struct PullRequestQuery;
)]
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;
@@ -289,8 +331,8 @@ impl query::QueryFn for ListPullRequests {
}
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(),
| Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter),
| None => "is:pr archived:false sort:updated-desc".into(),
};
let gql =
@@ -312,19 +354,19 @@ impl query::QueryFn for ListPullRequests {
.flatten()
.filter_map(|edge| {
edge.node.and_then(|n| match n {
| 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,
| 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,
})
})
.collect::<Vec<_>>()
@@ -369,36 +411,149 @@ impl query::QueryFn for FetchPullRequest {
"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
))
})?;
| 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 {
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,
}),
base_branch_name: p.base_ref.map(|r| r.name),
head_branch_name: p.head_ref.map(|r| r.name),
created_at: Some(created_at),
})
}
| _ => Err(api::Error::MalformedResponse(
"unexpected node type on PullRequestQuery".into(),
)),
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,
}),
base_repo_slug: p
.base_repository
.map(|it| it.name_with_owner)
.unwrap_or_default(),
base_branch_name: p.base_ref_name,
base_ref: p.base_ref_oid,
head_repo_slug: p
.head_repository
.map(|it| it.name_with_owner)
.unwrap_or_default(),
head_branch_name: p.head_ref_name,
head_ref: p.head_ref_oid,
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 = Vec<ChangedFile>;
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<Self::Data, Self::Error> {
#[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.clone().into(),
first: self.first,
});
let res = c.github_graphql_request(&gql)?.send().await?;
api::parse_graphql_response::<PullRequestFileTreeResponse>(res)
.await?
.1
};
#[cfg(not(debug_assertions))]
let data = {
let gql =
PullRequestFileTreeQuery::build_query(pull_request_file_tree_query::Variables {
id: self.id.clone().into(),
first: self.first,
});
let res = c.github_graphql_request(&gql)?.send().await?;
api::parse_graphql_response::<PullRequestFileTreeResponse>(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,
viewer_viewed_state: node.viewer_viewed_state,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default())
}
}
#[derive(Clone)]
pub(crate) struct FetchPullRequestTimeline {
pub(crate) id: Id,
@@ -406,12 +561,6 @@ pub(crate) struct FetchPullRequestTimeline {
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;
@@ -437,11 +586,11 @@ impl query::QueryFn for FetchPullRequestTimeline {
TimelineActor {
kind: match on {
| actorFieldsOn::Bot => "Bot",
| actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount",
| actorFieldsOn::Mannequin => "Mannequin",
| actorFieldsOn::Organization => "Organization",
| actorFieldsOn::User => "User",
| actorFieldsOn::Bot => "Bot",
| actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount",
| actorFieldsOn::Mannequin => "Mannequin",
| actorFieldsOn::Organization => "Organization",
| actorFieldsOn::User => "User",
}
.into(),
name: login,
@@ -451,62 +600,62 @@ impl query::QueryFn for FetchPullRequestTimeline {
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),
},
| 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),
},
| 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",
| PullRequestReviewState::PENDING => "PENDING",
| PullRequestReviewState::COMMENTED => "COMMENTED",
| PullRequestReviewState::APPROVED => "APPROVED",
| PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED",
| PullRequestReviewState::DISMISSED => "DISMISSED",
| _ => "OTHER",
}
.into()
}
@@ -726,10 +875,10 @@ impl query::QueryFn for FetchPullRequestTimeline {
"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(),
)),
| PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request),
| _ => Err(api::Error::MalformedResponse(
"unexpected node type on PullRequestTimelineQuery".into(),
)),
})?;
let timeline = pull_request.timeline_items;