wip: pr file diffing
This commit is contained in:
@@ -7,11 +7,15 @@ query PullRequestQuery($id: ID!) {
|
||||
state
|
||||
isDraft
|
||||
createdAt
|
||||
baseRef {
|
||||
name
|
||||
baseRefName
|
||||
baseRefOid
|
||||
headRefName
|
||||
headRefOid
|
||||
baseRepository {
|
||||
nameWithOwner
|
||||
}
|
||||
headRef {
|
||||
name
|
||||
headRepository {
|
||||
nameWithOwner
|
||||
}
|
||||
author {
|
||||
__typename
|
||||
|
||||
19
src/api/graphql/fetch_pull_request_file_tree.graphql
Normal file
19
src/api/graphql/fetch_pull_request_file_tree.graphql
Normal file
@@ -0,0 +1,19 @@
|
||||
query PullRequestFileTreeQuery($id: ID!, $first: Int!) {
|
||||
node(id: $id) {
|
||||
__typename
|
||||
... on PullRequest {
|
||||
files(first: $first) {
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
changeType
|
||||
additions
|
||||
deletions
|
||||
path
|
||||
viewerViewedState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
181
src/api/mock.rs
181
src/api/mock.rs
@@ -24,6 +24,27 @@ pub(crate) fn list_repos() -> Result<Vec<repo::Repository>, api::Error> {
|
||||
parse_fixture("repo.list", repo_list())
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_file_content(
|
||||
repo_slug: &str,
|
||||
path: &str,
|
||||
reff: Option<&str>,
|
||||
) -> Result<bytes::Bytes, api::Error> {
|
||||
let (owner, repo) = repo_slug.split_once('/').ok_or_else(|| {
|
||||
api::Error::MalformedResponse(format!(
|
||||
"invalid repo slug for repo.file_content fixture: {repo_slug}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let content = repo_file_content(owner, repo, path, reff).ok_or_else(|| {
|
||||
api::Error::MissingMockFixture(format!(
|
||||
"repo.file_content repo_slug={repo_slug} owner={owner} repo={repo} path={path} ref={}",
|
||||
reff.unwrap_or_default()
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(bytes::Bytes::from_static(content.as_bytes()))
|
||||
}
|
||||
|
||||
pub(crate) fn list_pull_requests(
|
||||
filter: Option<&str>,
|
||||
page: u32,
|
||||
@@ -52,6 +73,17 @@ pub(crate) fn fetch_pull_request(
|
||||
parse_fixture(&format!("issues.pull_request.{id}"), json)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_pull_request_file_tree(
|
||||
id: &issues::Id,
|
||||
) -> Result<issues::PullRequestFileTreeResponse, api::Error> {
|
||||
let id = id.to_string();
|
||||
let json = issues_pull_request_file_tree(&id).ok_or_else(|| {
|
||||
api::Error::MissingMockFixture(format!("issues.pull_request_file_tree id={id}"))
|
||||
})?;
|
||||
|
||||
parse_fixture(&format!("issues.pull_request_file_tree.{id}"), json)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_pull_request_timeline(
|
||||
id: &issues::Id,
|
||||
after: Option<&str>,
|
||||
@@ -146,10 +178,10 @@ mod tests {
|
||||
merged.author.as_ref().map(|author| author.login.as_str()),
|
||||
Some("rorycraft")
|
||||
);
|
||||
assert_eq!(merged.base_branch_name.as_deref(), Some("main"));
|
||||
assert_eq!(merged.base_branch_name.as_str(), "main");
|
||||
assert_eq!(
|
||||
merged.head_branch_name.as_deref(),
|
||||
Some("feat/release-handoff-checklist")
|
||||
merged.head_branch_name.as_str(),
|
||||
"feat/release-handoff-checklist"
|
||||
);
|
||||
assert_eq!(
|
||||
merged.created_at,
|
||||
@@ -167,23 +199,28 @@ mod tests {
|
||||
.map(|author| author.login.as_str()),
|
||||
Some("kennethnym")
|
||||
);
|
||||
assert_eq!(documented_failover.base_branch_name.as_str(), "main");
|
||||
assert_eq!(
|
||||
documented_failover.base_branch_name.as_deref(),
|
||||
Some("main")
|
||||
);
|
||||
assert_eq!(
|
||||
documented_failover.head_branch_name.as_deref(),
|
||||
Some("docs/manual-failover-steps")
|
||||
documented_failover.head_branch_name.as_str(),
|
||||
"docs/manual-failover-steps"
|
||||
);
|
||||
assert_eq!(
|
||||
documented_failover.created_at,
|
||||
Some(chrono::DateTime::parse_from_rfc3339("2026-04-24T06:40:00Z").unwrap())
|
||||
);
|
||||
assert!(dashboard_markdown.body.contains("```rust"));
|
||||
assert_eq!(dashboard_markdown.base_branch_name.as_deref(), Some("main"));
|
||||
assert_eq!(dashboard_markdown.base_branch_name.as_str(), "main");
|
||||
assert_eq!(
|
||||
dashboard_markdown.head_branch_name.as_deref(),
|
||||
Some("feat/cached-issue-pane")
|
||||
dashboard_markdown.head_branch_name.as_str(),
|
||||
"feat/cached-issue-pane"
|
||||
);
|
||||
assert_eq!(
|
||||
dashboard_markdown.base_ref.as_str(),
|
||||
"5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1"
|
||||
);
|
||||
assert_eq!(
|
||||
dashboard_markdown.head_ref.as_str(),
|
||||
"2bc41de7731b9ef48f7d64ee9f0d5f497dbe0a51"
|
||||
);
|
||||
assert_eq!(
|
||||
dashboard_markdown.created_at,
|
||||
@@ -196,10 +233,18 @@ mod tests {
|
||||
.map(|author| author.login.as_str()),
|
||||
Some("kennethnym")
|
||||
);
|
||||
assert_eq!(cached_repo_picker.base_branch_name.as_deref(), Some("main"));
|
||||
assert_eq!(cached_repo_picker.base_branch_name.as_str(), "main");
|
||||
assert_eq!(
|
||||
cached_repo_picker.head_branch_name.as_deref(),
|
||||
Some("feat/cached-repo-picker")
|
||||
cached_repo_picker.head_branch_name.as_str(),
|
||||
"feat/cached-repo-picker"
|
||||
);
|
||||
assert_eq!(
|
||||
cached_repo_picker.base_ref.as_str(),
|
||||
"5e8745bfcc0c90c226d3c6af84226d6d4a5ec2d1"
|
||||
);
|
||||
assert_eq!(
|
||||
cached_repo_picker.head_ref.as_str(),
|
||||
"13af7d0b48a6ce0b22d48c9b6c1c78dfcd94e6a0"
|
||||
);
|
||||
assert_eq!(
|
||||
cached_repo_picker.created_at,
|
||||
@@ -212,10 +257,10 @@ mod tests {
|
||||
.map(|author| author.login.as_str()),
|
||||
Some("leaferiksen")
|
||||
);
|
||||
assert_eq!(worker_split.base_branch_name.as_deref(), Some("main"));
|
||||
assert_eq!(worker_split.base_branch_name.as_str(), "main");
|
||||
assert_eq!(
|
||||
worker_split.head_branch_name.as_deref(),
|
||||
Some("feat/worker-context-envelope")
|
||||
worker_split.head_branch_name.as_str(),
|
||||
"feat/worker-context-envelope"
|
||||
);
|
||||
assert_eq!(
|
||||
worker_split.created_at,
|
||||
@@ -228,10 +273,10 @@ mod tests {
|
||||
.map(|author| author.login.as_str()),
|
||||
Some("mariahops")
|
||||
);
|
||||
assert_eq!(spacing_tokens.base_branch_name.as_deref(), Some("main"));
|
||||
assert_eq!(spacing_tokens.base_branch_name.as_str(), "main");
|
||||
assert_eq!(
|
||||
spacing_tokens.head_branch_name.as_deref(),
|
||||
Some("chore/dashboard-spacing-scale")
|
||||
spacing_tokens.head_branch_name.as_str(),
|
||||
"chore/dashboard-spacing-scale"
|
||||
);
|
||||
assert_eq!(
|
||||
spacing_tokens.created_at,
|
||||
@@ -239,6 +284,100 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_file_content_fixtures_align_with_pull_request_refs() {
|
||||
let dashboard_markdown = fetch_pull_request(&issues::Id::from("PR_kwDONovem84"))
|
||||
.expect("dashboard pull request fixture should parse");
|
||||
let cached_repo_picker = fetch_pull_request(&issues::Id::from("PR_kwDONovem85"))
|
||||
.expect("repo picker pull request fixture should parse");
|
||||
|
||||
let base_query = fetch_file_content(
|
||||
"kennethnym/novem",
|
||||
"src/query.rs",
|
||||
Some(dashboard_markdown.base_ref.as_str()),
|
||||
)
|
||||
.expect("base query fixture should exist");
|
||||
let head_query = fetch_file_content(
|
||||
"kennethnym/novem",
|
||||
"src/query.rs",
|
||||
Some(dashboard_markdown.head_ref.as_str()),
|
||||
)
|
||||
.expect("head query fixture should exist");
|
||||
let base_query = std::str::from_utf8(base_query.as_ref())
|
||||
.expect("base query fixture should be utf-8 text");
|
||||
let head_query = std::str::from_utf8(head_query.as_ref())
|
||||
.expect("head query fixture should be utf-8 text");
|
||||
|
||||
assert!(base_query.contains("pub struct CachedSelection"));
|
||||
assert!(head_query.contains("pub struct CachedQueryState"));
|
||||
assert!(head_query.contains("selected_pull_request_id"));
|
||||
assert!(head_query.contains("reconcile_scroll_anchor"));
|
||||
assert_ne!(base_query, head_query);
|
||||
|
||||
let base_repo = fetch_file_content(
|
||||
"kennethnym/novem",
|
||||
"src/api/repo.rs",
|
||||
Some(cached_repo_picker.base_ref.as_str()),
|
||||
)
|
||||
.expect("base repo fixture should exist");
|
||||
let head_repo = fetch_file_content(
|
||||
"kennethnym/novem",
|
||||
"src/api/repo.rs",
|
||||
Some(cached_repo_picker.head_ref.as_str()),
|
||||
)
|
||||
.expect("head repo fixture should exist");
|
||||
let base_repo =
|
||||
std::str::from_utf8(base_repo.as_ref()).expect("base repo fixture should be utf-8");
|
||||
let head_repo =
|
||||
std::str::from_utf8(head_repo.as_ref()).expect("head repo fixture should be utf-8");
|
||||
|
||||
assert!(base_repo.contains("pub fn filter_repositories"));
|
||||
assert!(head_repo.contains("pub struct RepoMatch"));
|
||||
assert!(head_repo.contains("pub fn build_content_path"));
|
||||
assert_ne!(base_repo, head_repo);
|
||||
|
||||
let _ = fetch_pull_request_file_tree(&issues::Id::from("PR_kwDONovem84"))
|
||||
.expect("pull request file tree fixture should parse");
|
||||
|
||||
let file_tree_json: serde_json::Value = serde_json::from_str(
|
||||
issues_pull_request_file_tree("PR_kwDONovem84")
|
||||
.expect("pull request file tree fixture json should exist"),
|
||||
)
|
||||
.expect("pull request file tree fixture json should parse");
|
||||
|
||||
let file_paths = file_tree_json
|
||||
.get("node")
|
||||
.and_then(|node| node.get("files"))
|
||||
.and_then(|files| files.get("edges"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.expect("pull request file tree fixture should contain file edges")
|
||||
.iter()
|
||||
.filter_map(|edge| edge.get("node"))
|
||||
.filter_map(|node| node.get("path"))
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
file_paths,
|
||||
vec!["src/query.rs", "src/screen/dashboard/issue_list.rs"]
|
||||
);
|
||||
|
||||
for path in file_paths {
|
||||
fetch_file_content(
|
||||
dashboard_markdown.base_repo_slug.as_str(),
|
||||
path,
|
||||
Some(dashboard_markdown.base_ref.as_str()),
|
||||
)
|
||||
.unwrap_or_else(|_| panic!("base fixture should exist for {path}"));
|
||||
fetch_file_content(
|
||||
dashboard_markdown.head_repo_slug.as_str(),
|
||||
path,
|
||||
Some(dashboard_markdown.head_ref.as_str()),
|
||||
)
|
||||
.unwrap_or_else(|_| panic!("head fixture should exist for {path}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_timeline_fixtures_parse() {
|
||||
let first_page = fetch_pull_request_timeline(&issues::Id::from("PR_kwDOSprint62"), None)
|
||||
|
||||
126
src/api/repo.rs
126
src/api/repo.rs
@@ -1,6 +1,13 @@
|
||||
use futures::{FutureExt, TryFutureExt};
|
||||
use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::OwnedRwLockReadGuard;
|
||||
|
||||
use crate::{api, query};
|
||||
use crate::{
|
||||
api,
|
||||
query::{self, Query, fetch_query},
|
||||
util::file,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Repository {
|
||||
@@ -22,6 +29,13 @@ pub struct Owner {
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileRef {
|
||||
pub repo_slug: String,
|
||||
pub path: String,
|
||||
pub reff: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct List;
|
||||
|
||||
@@ -50,3 +64,113 @@ impl query::QueryFn for List {
|
||||
api::parse_response(res).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FetchFileContent {
|
||||
pub repo_slug: String,
|
||||
pub path: String,
|
||||
pub reff: Option<String>,
|
||||
}
|
||||
|
||||
impl query::QueryFn for FetchFileContent {
|
||||
type Data = bytes::Bytes;
|
||||
type Error = api::Error;
|
||||
type Context = api::QueryContext;
|
||||
|
||||
fn key(&self) -> query::Key {
|
||||
match &self.reff {
|
||||
| Some(reff) => format!("repo/fetch/{}/{}/{}", self.repo_slug, self.path, reff).into(),
|
||||
| None => format!("repo/fetch/{}/{}", self.repo_slug, self.path).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_file_content(
|
||||
&self.repo_slug,
|
||||
&self.path,
|
||||
self.reff.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
let path = match &self.reff {
|
||||
| Some(reff) => format!(
|
||||
"/repos/{}/contents/{}?ref={}",
|
||||
self.repo_slug, self.path, reff
|
||||
),
|
||||
| None => format!("/repos/{}/contents/{}", self.repo_slug, self.path),
|
||||
};
|
||||
|
||||
let res = c
|
||||
.github_request(Method::GET, &path)?
|
||||
.header("Accept", "application/vnd.github.raw+json")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
api::raw_content(res).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QueryFileDiff {
|
||||
pub base: FileRef,
|
||||
pub head: FileRef,
|
||||
}
|
||||
|
||||
impl query::QueryFn for QueryFileDiff {
|
||||
type Data = Option<()>;
|
||||
type Error = api::Error;
|
||||
type Context = api::QueryContext;
|
||||
|
||||
fn key(&self) -> query::Key {
|
||||
format!(
|
||||
"repo/diff/{}/{}/{}",
|
||||
self.base.repo_slug, self.base.path, self.head.path
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> {
|
||||
async fn fetch_content(
|
||||
r: &FileRef,
|
||||
c: &<QueryFileDiff as query::QueryFn>::Context,
|
||||
) -> Result<Option<bytes::Bytes>, api::Error> {
|
||||
let path = match &r.reff {
|
||||
| Some(reff) => format!("/repos/{}/contents/{}?ref={}", r.repo_slug, r.path, reff),
|
||||
| None => format!("/repos/{}/contents/{}", r.repo_slug, r.path),
|
||||
};
|
||||
|
||||
let res = c
|
||||
.github_request(Method::GET, &path)?
|
||||
.header("Accept", "application/vnd.github.raw+json")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
res.headers().get("Content-Type");
|
||||
|
||||
let bytes = api::raw_content(res).await?;
|
||||
let file::ContentType::Text = file::classify_content(&bytes) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
let (old, new) = tokio::join!(fetch_content(&self.base, c), fetch_content(&self.head, c),);
|
||||
|
||||
match (old, new) {
|
||||
| (Ok(Some(ref old)), Ok(Some(ref new))) => {
|
||||
let diff = similar::TextDiff::from_lines::<[u8]>(old, new);
|
||||
for change in diff.iter_all_changes() {}
|
||||
}
|
||||
| _ => {
|
||||
return Err(api::Error::MalformedResponse(
|
||||
"failed to fetch content".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user