refactor: migrate to github gql api

This commit is contained in:
2026-05-08 02:23:28 +08:00
parent 7de0039d38
commit 19769a7b75
11 changed files with 177422 additions and 228 deletions

View File

@@ -115,3 +115,13 @@ where
})
}
}
async fn parse_graphql_response<T>(
res: reqwest::Response,
) -> Result<graphql_client::Response<T>, Error>
where
T: serde::de::DeserializeOwned,
{
let data: graphql_client::Response<T> = res.json().await?;
Ok(data)
}

View File

@@ -33,7 +33,7 @@ impl query::QueryFn for CreateDeviceCode {
let res = c
.http
.post(format!(
"https://github.com/login/device/code?client_id={}",
"https://github.com/login/device/code?client_id={}&scope=repo,read:user",
c.github.client_id
))
.header("Accept", "application/json")

View File

@@ -0,0 +1,28 @@
query PullRequestQuery($query: String!) {
search(query: $query, first: 10, type: ISSUE) {
issueCount
edges {
node {
__typename
... on PullRequest {
isDraft
title
state
repository {
name
owner {
__typename
login
}
}
}
}
cursor
}
pageInfo {
startCursor
endCursor
hasNextPage
}
}
}

177175
src/api/graphql/schema.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,168 +1,51 @@
use std::ops::Deref;
use graphql_client::{GraphQLQuery, Response};
use reqwest::Method;
use serde::Deserialize;
use crate::{
api::{self, user},
api::{
self,
issues::pull_request_query::{PullRequestQuerySearchEdgesNode, PullRequestState},
},
query,
};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize)]
#[serde(transparent)]
#[repr(transparent)]
pub(crate) struct Id(u64);
#[derive(Debug, Deserialize)]
pub(crate) struct Issue {
pub(crate) id: Id,
pub(crate) node_id: String,
pub(crate) url: String,
pub(crate) repository_url: String,
pub(crate) labels_url: String,
pub(crate) comments_url: String,
pub(crate) events_url: String,
pub(crate) html_url: String,
pub(crate) number: u64,
pub(crate) state: String,
pub(crate) state_reason: Option<String>,
pub(crate) title: String,
pub(crate) body: Option<String>,
pub(crate) body_text: Option<String>,
pub(crate) body_html: Option<String>,
pub(crate) user: Option<user::User>,
pub(crate) labels: Vec<Label>,
pub(crate) assignee: Option<user::User>,
#[serde(default)]
pub(crate) assignees: Vec<user::User>,
pub(crate) milestone: Option<Milestone>,
pub(crate) locked: bool,
pub(crate) active_lock_reason: Option<String>,
pub(crate) comments: u64,
pub(crate) pull_request: Option<PullRequest>,
pub(crate) closed_at: Option<String>,
pub(crate) created_at: String,
pub(crate) updated_at: String,
pub(crate) closed_by: Option<user::User>,
pub(crate) author_association: String,
pub(crate) draft: Option<bool>,
pub(crate) timeline_url: Option<String>,
pub(crate) repository: Option<Repository>,
pub(crate) performed_via_github_app: Option<serde_json::Value>,
pub(crate) reactions: Option<Reactions>,
pub(crate) pinned_comment: Option<serde_json::Value>,
#[serde(rename = "type")]
pub(crate) issue_type: Option<IssueType>,
pub(crate) sub_issues_summary: Option<SubIssuesSummary>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum Label {
Name(String),
Detail(LabelDetail),
}
#[derive(Debug, Deserialize)]
pub(crate) struct LabelDetail {
pub(crate) id: u64,
pub(crate) node_id: String,
pub(crate) url: String,
pub(crate) name: String,
pub(crate) description: Option<String>,
pub(crate) color: String,
pub(crate) default: bool,
}
#[derive(Debug, Deserialize)]
pub(crate) struct Milestone {
pub(crate) url: String,
pub(crate) html_url: String,
pub(crate) labels_url: String,
pub(crate) id: u64,
pub(crate) node_id: String,
pub(crate) number: u64,
pub(crate) state: String,
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) creator: Option<user::User>,
pub(crate) open_issues: u64,
pub(crate) closed_issues: u64,
pub(crate) created_at: String,
pub(crate) updated_at: String,
pub(crate) closed_at: Option<String>,
pub(crate) due_on: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct PullRequest {
pub(crate) url: String,
pub(crate) html_url: String,
pub(crate) diff_url: String,
pub(crate) patch_url: String,
pub(crate) merged_at: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct Repository {
pub(crate) id: u64,
pub(crate) node_id: String,
pub(crate) name: String,
pub(crate) full_name: String,
pub(crate) owner: user::User,
pub(crate) private: bool,
pub(crate) html_url: String,
pub(crate) description: Option<String>,
pub(crate) fork: bool,
pub(crate) url: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct Reactions {
pub(crate) url: String,
pub(crate) total_count: u64,
#[serde(rename = "+1")]
pub(crate) plus_one: u64,
#[serde(rename = "-1")]
pub(crate) minus_one: u64,
pub(crate) laugh: u64,
pub(crate) confused: u64,
pub(crate) heart: u64,
pub(crate) hooray: u64,
pub(crate) rocket: u64,
pub(crate) eyes: u64,
}
#[derive(Debug, Deserialize)]
pub(crate) struct IssueType {
pub(crate) id: u64,
pub(crate) node_id: String,
pub(crate) name: String,
pub(crate) description: Option<String>,
pub(crate) color: Option<String>,
pub(crate) created_at: Option<String>,
pub(crate) updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct SubIssuesSummary {
pub(crate) total: u64,
pub(crate) completed: u64,
pub(crate) percent_completed: f64,
}
pub(crate) struct Id(String);
impl Deref for Id {
type Target = u64;
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<u64> for Id {
fn from(id: u64) -> Self {
Self(id)
}
#[derive(Debug, Deserialize)]
pub(crate) struct PullRequestPaginatedResponse {
pub(crate) items: Vec<PullRequest>,
pub(crate) start_cursor: Option<String>,
pub(crate) end_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct PullRequest {
pub(crate) title: String,
pub(crate) state: IssueState,
pub(crate) is_draft: bool,
pub(crate) repo_slug: String,
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub(crate) enum IssueState {
Open,
Closed,
Merged,
Unknown,
}
impl std::fmt::Display for Id {
@@ -171,15 +54,24 @@ impl std::fmt::Display for Id {
}
}
impl Issue {
pub(crate) const FILTER_ASSIGNED: &'static str = "assigned";
pub(crate) const FILTER_CREATED: &'static str = "created";
pub(crate) const FILTER_MENTIONED: &'static str = "mentioned";
pub(crate) const FILTER_SUBSCRIBED: &'static str = "subscribed";
pub(crate) const FILTER_REPOS: &'static str = "repos";
pub(crate) const FILTER_ALL: &'static str = "all";
impl From<PullRequestState> for IssueState {
fn from(state: PullRequestState) -> Self {
match state {
PullRequestState::OPEN => Self::Open,
PullRequestState::CLOSED => Self::Closed,
PullRequestState::MERGED => Self::Merged,
_ => Self::Unknown,
}
}
}
#[derive(graphql_client::GraphQLQuery)]
#[graphql(
schema_path = "src/api/graphql/schema.json",
query_path = "src/api/graphql/list_pull_requests.graphql"
)]
struct PullRequestQuery;
#[derive(Clone)]
pub(crate) struct ListPullRequests {
pub filter: Option<&'static str>,
@@ -187,7 +79,7 @@ pub(crate) struct ListPullRequests {
}
impl query::QueryFn for ListPullRequests {
type Data = Vec<Issue>;
type Data = PullRequestPaginatedResponse;
type Error = api::Error;
type Context = api::QueryContext;
@@ -206,21 +98,54 @@ impl query::QueryFn for ListPullRequests {
return super::mock::list_pull_requests(self.filter, self.page);
}
let page_string = self.page.to_string();
let mut params: Vec<(&str, &str)> = Vec::with_capacity(4);
params.push(("pulls", "true"));
params.push(("page", &page_string));
params.push(("direction", "desc"));
if let Some(filter) = self.filter {
params.push(("filter", filter))
}
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(),
};
let gql = PullRequestQuery::build_query(pull_request_query::Variables {
query: query_string,
});
let res = c
.github_request(Method::GET, "/issues")?
.query(&params)
.github_request(Method::POST, "/graphql")?
.json(&gql)
.send()
.await?;
api::parse_response::<Vec<Issue>>(res).await
let data = api::parse_graphql_response::<pull_request_query::ResponseData>(res)
.await?
.data
.unwrap();
Ok(PullRequestPaginatedResponse {
items: data
.search
.edges
.map(|it| {
it.into_iter()
.flatten()
.filter_map(|edge| {
edge.node.and_then(|n| match n {
PullRequestQuerySearchEdgesNode::PullRequest(p) => {
Some(PullRequest {
title: p.title,
state: p.state.into(),
is_draft: p.is_draft,
repo_slug: format!(
"{}/{}",
p.repository.owner.login, p.repository.name
),
})
}
_ => None,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default(),
start_cursor: data.search.page_info.start_cursor,
end_cursor: data.search.page_info.end_cursor,
})
}
}

View File

@@ -27,7 +27,7 @@ pub(crate) fn list_repos() -> Result<Vec<repo::Repository>, api::Error> {
pub(crate) fn list_pull_requests(
filter: Option<&str>,
page: u32,
) -> Result<Vec<issues::Issue>, api::Error> {
) -> Result<issues::PullRequestPaginatedResponse, api::Error> {
let filter = filter.unwrap_or_default();
let json = issues_pull_requests(filter, page).ok_or_else(|| {
api::Error::MissingMockFixture(format!("issues.pull_requests filter={filter} page={page}"))

View File

@@ -44,7 +44,7 @@ fn setup_application(cx: &mut gpui::App) {
auth: None,
github: api::GithubCredentials {
base_url: "https://api.github.com",
client_id: "Iv23liZD4bMQpGJICsR7",
client_id: "Ov23linXmiNkn2gOvOmi",
},
#[cfg(debug_assertions)]

View File

@@ -29,15 +29,16 @@ struct IssueListItem {
repo_name: Option<gpui::SharedString>,
title: gpui::SharedString,
description: Option<gpui::SharedString>,
status: IssueStatus,
status: api::issues::IssueState,
is_last: bool,
is_draft: bool,
}
pub(crate) fn new(cx: &mut gpui::Context<IssueList>) -> IssueList {
let mut list = IssueList {
pr_query: use_query(
api::issues::ListPullRequests {
filter: Some(api::issues::Issue::FILTER_ALL),
filter: Some("author:@me state:open"),
page: 1,
},
cx,
@@ -54,33 +55,20 @@ impl IssueList {
fn on_create(&mut self, cx: &mut gpui::Context<Self>) {
cx.observe(&self.pr_query, |this, _, cx| {
let data = read_query(&this.pr_query, cx);
if let QueryStatus::Loaded(issues) = data {
if let QueryStatus::Loaded(res) = data {
let old_len = this.list_state.item_count();
let new_len = issues.len();
let new_len = res.items.len();
this.list_items = issues
.iter()
.enumerate()
.map(|(i, it)| IssueListItem {
repo_name: it.repository.as_ref().map(|it| {
gpui::SharedString::new(format!("{}/{}", it.owner.login, it.name))
}),
title: gpui::SharedString::new(it.title.as_str()),
description: it
.body_text
.as_ref()
.map(|it| gpui::SharedString::new(it.as_str())),
status: if it.state == "open" {
IssueStatus::Open
} else if it.state == "closed" {
IssueStatus::Closed
} else {
IssueStatus::Draft
},
is_last: i == new_len - 1,
})
.collect::<Vec<_>>();
let new_items = res.items.iter().enumerate().map(|(i, it)| IssueListItem {
repo_name: Some(gpui::SharedString::new(it.repo_slug.as_str())),
title: gpui::SharedString::new(it.title.as_str()),
description: None,
status: it.state,
is_last: i == new_len - 1,
is_draft: it.is_draft,
});
this.list_items.splice(old_len..old_len, new_items);
this.list_state.splice(old_len..old_len, new_len);
}
})
@@ -114,15 +102,19 @@ impl gpui::RenderOnce for IssueListItem {
.text_xs()
.opacity(0.5);
let icon = match self.status {
IssueStatus::Draft => font_icon(FontIcon::PullRequestDraft)
let icon = if self.is_draft {
font_icon(FontIcon::PullRequestDraft)
.text_color(theme.colors.text)
.opacity(0.5),
IssueStatus::Open => {
font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success)
}
IssueStatus::Closed => {
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger)
.opacity(0.5)
} else {
match self.status {
api::issues::IssueState::Closed => {
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.danger)
}
api::issues::IssueState::Merged => {
font_icon(FontIcon::PullRequestClosed).text_color(theme.colors.success)
}
_ => font_icon(FontIcon::PullRequestArrow).text_color(theme.colors.success),
}
}
.flex_shrink_0()

View File

@@ -4,7 +4,7 @@ use gpui::{
};
use crate::{
api, app,
app,
component::{
font_icon::{FontIcon, font_icon},
text::text,
@@ -34,29 +34,19 @@ pub enum SidebarItemValue {
pub fn new() -> Sidebar {
Sidebar {
selected_item_id: Some("all"),
selected_item_id: Some("authored"),
on_item_change: None,
}
}
impl Sidebar {
const ALL_ITEMS: [SidebarItem; 3] = [
SidebarItem {
id: "all",
title: "All",
icon: FontIcon::List,
value: SidebarItemValue::PullRequest {
filter: api::issues::Issue::FILTER_ALL,
},
is_selected: false,
on_click: None,
},
const ALL_ITEMS: [SidebarItem; 2] = [
SidebarItem {
id: "authored",
title: "Authored",
icon: FontIcon::PencilLine,
value: SidebarItemValue::PullRequest {
filter: api::issues::Issue::FILTER_CREATED,
filter: "author:@me",
},
is_selected: false,
on_click: None,
@@ -66,20 +56,12 @@ impl Sidebar {
title: "Assigned",
icon: FontIcon::UserPlus,
value: SidebarItemValue::PullRequest {
filter: api::issues::Issue::FILTER_ASSIGNED,
filter: "review-requested:@me",
},
is_selected: false,
on_click: None,
},
];
fn select_sidebar_item(&mut self, id: &str, cx: &mut gpui::Context<Self>) {
let Some(item) = Sidebar::ALL_ITEMS.iter().find(|item| item.id == id) else {
return;
};
self.selected_item_id = Some(item.id);
cx.notify();
}
}
impl Sidebar {