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

@@ -34,6 +34,8 @@ pub(crate) struct GithubCredentials {
#[derive(Debug)]
pub(crate) enum Error {
Unauthenticated,
NotAllowed,
DoesNotExist,
#[cfg(debug_assertions)]
MissingMockFixture(String),
Github(GithubError),
@@ -104,6 +106,18 @@ impl From<serde_json::Error> for Error {
}
}
async fn raw_content(res: reqwest::Response) -> Result<bytes::Bytes, Error> {
if res.status().is_success() {
Ok(res.bytes().await?)
} else {
match res.status() {
| reqwest::StatusCode::NOT_FOUND => Err(Error::DoesNotExist),
| reqwest::StatusCode::FORBIDDEN => Err(Error::NotAllowed),
| _ => Err(Error::MalformedResponse(res.status().to_string())),
}
}
}
async fn parse_response<T>(res: reqwest::Response) -> Result<T, Error>
where
T: serde::de::DeserializeOwned,

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

View File

@@ -87,8 +87,8 @@ impl gpui::RenderOnce for Button {
let theme = app::current_theme(cx);
let icon_color = match self.variant {
| Variant::Primary => theme.colors.accent_on_solid,
| Variant::Secondary => theme.colors.text,
| Variant::Primary => theme.colors.accent_on_solid,
| Variant::Secondary => theme.colors.text,
};
let mut children: Vec<AnyElement> = Vec::with_capacity(3);

108
src/component/code_view.rs Normal file
View File

@@ -0,0 +1,108 @@
use std::{rc::Rc, sync::Arc};
use gpui::{IntoElement, ParentElement, Styled, div, list, px, rems};
use crate::app;
#[derive(gpui::IntoElement, Clone)]
pub(crate) struct Line {
line_number_col_width: gpui::Pixels,
line_number: usize,
content: gpui::SharedString,
diff_marker: DiffMarker,
}
#[derive(Clone)]
enum DiffMarker {
Added,
Deleted,
Unchanged,
}
#[derive(Clone)]
struct CodeViewState(gpui::ListState);
#[derive(Clone)]
struct Lines(Rc<Vec<Line>>);
struct CodeView {
state: CodeViewState,
lines: Lines,
}
pub(crate) fn line(
line_number: usize,
content: impl Into<Arc<str>>,
diff_marker: DiffMarker,
) -> Line {
Line {
line_number,
diff_marker,
content: gpui::SharedString::new(content),
line_number_col_width: px(0.),
}
}
pub(crate) fn code_view(state: CodeViewState, lines: Lines) -> CodeView {
CodeView { state, lines }
}
impl FromIterator<Line> for Lines {
fn from_iter<T: IntoIterator<Item = Line>>(iter: T) -> Self {
Lines(Rc::new(iter.into_iter().collect()))
}
}
impl gpui::RenderOnce for CodeView {
fn render(self, window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
let digits = self
.lines
.0
.last()
.map(|l| l.line_number.to_string().len())
.unwrap_or(0);
let text_style = window.text_style();
let font_size = text_style.font_size.to_pixels(window.rem_size());
let font_id = window.text_system().resolve_font(&gpui::font("Menlo"));
let line_number_col_width = window
.text_system()
.ch_advance(font_id, font_size)
.unwrap_or(px(7.2))
* digits;
list(self.state.0, move |i, _window, _app| {
let mut line = self.lines.0[i].clone();
line.line_number_col_width = line_number_col_width;
div()
.flex()
.flex_row()
.items_start()
.w_full()
.child(line)
.into_any_element()
})
}
}
impl gpui::RenderOnce for Line {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
let theme = app::current_theme(cx);
div()
.flex()
.flex_row()
.font_family("Menlo")
.text_color(theme.colors.text)
.child(
div()
.bg(theme.colors.surface)
.w(self.line_number_col_width)
.text_align(gpui::TextAlign::Right)
.child(self.line_number.to_string()),
)
.child(self.content)
}
}

View File

@@ -1,4 +1,5 @@
pub(crate) mod button;
pub(crate) mod code_view;
pub(crate) mod font_icon;
pub(crate) mod markdown;
pub(crate) mod text;

View File

@@ -148,8 +148,8 @@ impl gpui::RenderOnce for IssueListItem {
}
let repo_name_text = match self.repo_name {
| Some(name) => text(name),
| None => text("Unknown repo"),
| Some(name) => text(name),
| None => text("Unknown repo"),
}
.text_xs()
.opacity(0.5);
@@ -162,21 +162,21 @@ impl gpui::RenderOnce for IssueListItem {
.bg(theme.colors.surface)
} else {
match self.status {
| api::issues::PullRequestState::Closed => pill(
text("Closed").text_color(theme.colors.danger_on_solid),
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger_on_solid),
)
.bg(theme.colors.danger_solid),
| api::issues::PullRequestState::Merged => pill(
text("Merged").text_color(theme.colors.accent_on_solid),
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.accent_on_solid),
)
.bg(theme.colors.accent_solid),
| _ => pill(
text("Open").text_color(theme.colors.success_on_solid),
font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success_on_solid),
)
.bg(theme.colors.success_solid),
| api::issues::PullRequestState::Closed => pill(
text("Closed").text_color(theme.colors.danger_on_solid),
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger_on_solid),
)
.bg(theme.colors.danger_solid),
| api::issues::PullRequestState::Merged => pill(
text("Merged").text_color(theme.colors.accent_on_solid),
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.accent_on_solid),
)
.bg(theme.colors.accent_solid),
| _ => pill(
text("Open").text_color(theme.colors.success_on_solid),
font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success_on_solid),
)
.bg(theme.colors.success_solid),
}
};

View File

@@ -1,4 +1,5 @@
mod issue_list;
mod pull_request_diff_view;
mod pull_request_view;
mod screen;
mod sidebar;

View File

@@ -0,0 +1,79 @@
use crate::{
api,
query::{self, QueryStatus, read_query, use_query},
};
pub(crate) struct PullRequestDiffView {
selected_file_path: Option<String>,
pr_query: query::Entity<api::issues::FetchPullRequest>,
old_content_query: Option<query::Entity<api::repo::FetchFileContent>>,
new_content_query: Option<query::Entity<api::repo::FetchFileContent>>,
}
fn new(pr_id: api::issues::Id, cx: &mut gpui::Context<PullRequestDiffView>) -> PullRequestDiffView {
let mut view = PullRequestDiffView {
selected_file_path: None,
pr_query: use_query(api::issues::FetchPullRequest { id: pr_id }, cx),
old_content_query: None,
new_content_query: None,
};
view.on_create(cx);
view
}
impl PullRequestDiffView {
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
_ = cx
.observe(&self.pr_query, |this, _, cx| {
this.start_content_queries(cx);
})
.detach();
// if pr is already loaded, start content queries
self.start_content_queries(cx);
}
fn start_content_queries(&mut self, cx: &mut gpui::Context<Self>) {
let Some((old_content_query, new_content_query)) = ({
if let QueryStatus::Loaded(pr) = read_query(&self.pr_query, cx) {
Some((
api::repo::FetchFileContent {
repo_slug: pr.base_repo_slug.clone(),
path: pr.base_branch_name.clone(),
reff: Some(pr.base_ref.clone()),
},
api::repo::FetchFileContent {
repo_slug: pr.head_repo_slug.clone(),
path: pr.head_branch_name.clone(),
reff: Some(pr.head_ref.clone()),
},
))
} else {
None
}
}) else {
return;
};
let old_content_query = use_query(old_content_query, cx);
let new_content_query = use_query(new_content_query, cx);
_ = cx.observe(&old_content_query, |this, _, cx| {}).detach();
_ = cx.observe(&new_content_query, |this, _, cx| {}).detach();
self.old_content_query = Some(old_content_query);
self.new_content_query = Some(new_content_query);
}
}
impl gpui::Render for PullRequestDiffView {
fn render(
&mut self,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
todo!()
}
}

View File

@@ -13,10 +13,12 @@ use crate::{
text::text,
},
query::{self, QueryStatus, read_query, use_query},
screen::dashboard::pull_request_diff_view::PullRequestDiffView,
};
pub(crate) struct PullRequestView {
markdown_viewer: Option<gpui::Entity<MarkdownText>>,
diff_view: Option<gpui::Entity<PullRequestDiffView>>,
pull_request_query: Option<query::Entity<api::issues::FetchPullRequest>>,
}
@@ -27,6 +29,7 @@ struct Toolbar {}
pub fn new(_cx: &mut gpui::Context<PullRequestView>) -> PullRequestView {
PullRequestView {
markdown_viewer: None,
diff_view: None,
pull_request_query: None,
}
}
@@ -126,12 +129,9 @@ impl PullRequestView {
}
}
let merge_text = match (
pr.author.as_ref(),
pr.base_branch_name.as_ref(),
pr.head_branch_name.as_ref(),
) {
| (Some(author), Some(base_branch), Some(head_branch)) => {
let merge_text = pr.author.as_ref().map(|author| {
let base_branch = pr.base_branch_name.as_str();
let head_branch = pr.head_branch_name.as_str();
let str = format!(
"{} requested to merge {} into {}",
author.login, head_branch, base_branch
@@ -166,14 +166,11 @@ impl PullRequestView {
),
];
Some((
(
author,
gpui::StyledText::new(str).with_highlights(highlights),
))
}
| _ => None,
};
)
});
let metadata_line =
div()

View File

@@ -33,9 +33,9 @@ impl Screen {
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
_ = cx
.subscribe(&self.issue_list, |this, _, event, cx| match event {
| issue_list::Event::ItemSelected(pr_id) => {
this.handle_issue_list_item_selected(pr_id, cx);
}
| issue_list::Event::ItemSelected(pr_id) => {
this.handle_issue_list_item_selected(pr_id, cx);
}
})
.detach();
}

48
src/util/file.rs Normal file
View File

@@ -0,0 +1,48 @@
use memchr::memchr;
pub(crate) enum ContentType {
Text,
Binary,
}
pub(crate) struct ContentDiff {
old_content: bytes::Bytes,
new_content: bytes::Bytes,
}
pub(crate) struct LineDiff {
old_line: Option<usize>,
old_content_range: std::ops::Range<usize>,
new_line: Option<usize>,
new_content_range: std::ops::Range<usize>,
}
pub(crate) fn classify_content(content: &[u8]) -> ContentType {
if content.is_empty() {
ContentType::Text
} else if content.starts_with(&[0xEF, 0xBB, 0xBF]) // UTF-8
|| content.starts_with(&[0x00, 0x00, 0xFE, 0xFF]) // UTF-32 BE
|| content.starts_with(&[0xFF, 0xFE, 0x00, 0x00]) // UTF-32 LE
|| content.starts_with(&[0xFE, 0xFF]) // UTF-16 BE
|| content.starts_with(&[0xFF, 0xFE])
{
ContentType::Text
} else {
match memchr(0, &content[0..8192]) {
| None => ContentType::Text,
| Some(_) => ContentType::Binary,
}
}
}
pub(crate) fn diff_content(old: &[u8], new: &[u8]) -> ContentDiff {
similar::TextDiff::from_lines::<[u8]>(old, new)
.iter_all_changes()
.map(|change| LineDiff {
old_line: change.old_index(),
old_content_range: change.old_range,
new_line: change.new_index(),
new_content_range: change.new_range,
})
.collect()
}

View File

@@ -1 +1,2 @@
pub(crate) mod file;
pub(crate) mod timeout;