feat: impl dashboard & issue list

This commit is contained in:
2026-05-06 01:42:38 +08:00
parent bef3a0b9ed
commit 7de0039d38
36 changed files with 2381 additions and 107 deletions

View File

@@ -2,10 +2,7 @@ use std::collections::HashMap;
use serde::Deserialize;
use crate::{
api,
query::{self, use_query},
};
use crate::{api, query};
pub(crate) const DEVICE_LOGIN_FLOW_URL: &str = "https://github.com/login/device";
@@ -28,8 +25,8 @@ impl query::QueryFn for CreateDeviceCode {
type Error = api::Error;
type Context = api::QueryContext;
fn key(&self) -> &'static str {
"auth.device_code"
fn key(&self) -> query::Key {
"auth/device_code".into()
}
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> {
@@ -64,8 +61,8 @@ impl query::QueryFn for RequestAccessToken {
type Error = api::Error;
type Context = api::QueryContext;
fn key(&self) -> &'static str {
"auth.access_token"
fn key(&self) -> query::Key {
"auth.access_token".into()
}
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> {

226
src/api/issues.rs Normal file
View File

@@ -0,0 +1,226 @@
use std::ops::Deref;
use reqwest::Method;
use serde::Deserialize;
use crate::{
api::{self, user},
query,
};
#[derive(Debug, Default, Clone, Copy, 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,
}
impl Deref for Id {
type Target = u64;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<u64> for Id {
fn from(id: u64) -> Self {
Self(id)
}
}
impl std::fmt::Display for Id {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
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";
}
#[derive(Clone)]
pub(crate) struct ListPullRequests {
pub filter: Option<&'static str>,
pub page: u32,
}
impl query::QueryFn for ListPullRequests {
type Data = Vec<Issue>;
type Error = api::Error;
type Context = api::QueryContext;
fn key(&self) -> query::Key {
format!(
"issues/list?pulls=true&page={}&filter={}",
self.page,
self.filter.unwrap_or_default()
)
.into()
}
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> {
#[cfg(debug_assertions)]
if c.should_use_fixtures {
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 res = c
.github_request(Method::GET, "/issues")?
.query(&params)
.send()
.await?;
api::parse_response::<Vec<Issue>>(res).await
}
}

47
src/api/mock.rs Normal file
View File

@@ -0,0 +1,47 @@
use serde::de::DeserializeOwned;
use crate::api::{self, issues, repo, user};
include!(concat!(env!("OUT_DIR"), "/github_fixtures.rs"));
pub(super) fn is_enabled() -> bool {
std::env::var("NOVEM_GITHUB_FIXTURES")
.ok()
.map(|value| {
!matches!(
value.trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "off"
)
})
.unwrap_or(false)
}
pub(crate) fn fetch_user() -> Result<user::User, api::Error> {
parse_fixture("user.fetch", user_fetch())
}
pub(crate) fn list_repos() -> Result<Vec<repo::Repository>, api::Error> {
parse_fixture("repo.list", repo_list())
}
pub(crate) fn list_pull_requests(
filter: Option<&str>,
page: u32,
) -> Result<Vec<issues::Issue>, 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}"))
})?;
parse_fixture(&format!("issues.pull_requests.{filter}.page{page}"), json)
}
fn parse_fixture<T>(name: &str, json: &'static str) -> Result<T, api::Error>
where
T: DeserializeOwned,
{
serde_json::from_str(json).map_err(|err| {
println!("[mock fixture] failed to parse {name}: {err}");
api::Error::MalformedResponse(err)
})
}

View File

@@ -1,19 +1,52 @@
use crate::api::QueryContext;
use crate::query;
use serde::Deserialize;
use crate::{api, query};
#[derive(Debug, Deserialize)]
pub struct Repository {
pub id: u64,
pub name: String,
pub full_name: String,
pub private: bool,
pub html_url: String,
pub description: Option<String>,
pub default_branch: String,
pub owner: Owner,
}
#[derive(Debug, Deserialize)]
pub struct Owner {
pub login: String,
pub id: api::user::Id,
pub avatar_url: String,
pub html_url: String,
}
#[derive(Clone)]
pub struct List;
impl query::QueryFn for List {
type Data = ();
type Error = ();
type Context = QueryContext;
type Data = Vec<Repository>;
type Error = api::Error;
type Context = api::QueryContext;
fn key(&self) -> &'static str {
"repo.list"
fn key(&self) -> query::Key {
"repo/list".into()
}
async fn run(&self, _c: &Self::Context) -> Result<Self::Data, Self::Error> {
Ok(())
async fn run(&self, c: &Self::Context) -> Result<Self::Data, Self::Error> {
#[cfg(debug_assertions)]
if c.should_use_fixtures {
return super::mock::list_repos();
}
let params = [("sort", "updated"), ("per_page", "100")];
let res = c
.github_request(reqwest::Method::GET, "/user/repos")?
.query(&params)
.send()
.await?;
api::parse_response(res).await
}
}

View File

@@ -48,11 +48,16 @@ impl query::QueryFn for Fetch {
type Error = api::Error;
type Context = api::QueryContext;
fn key(&self) -> &'static str {
"user"
fn key(&self) -> query::Key {
"user".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_user();
}
let res = c.github_request(Method::GET, "/user")?.send().await?;
api::parse_response(res).await
}