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

@@ -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

View 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
}
}
}
}
}
}

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;

View File

@@ -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)

View File

@@ -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!()
}
}