Skip to content
This repository was archived by the owner on Nov 6, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Tracing with a filter set by `RUST_LOG` environment variable.
- Added support for passing the SSH passphrase through the `SSH_PASSPHRASE`
environment variable.
- Added new GraphQL API: `issueStat` query. Users can filter issues by
`assignee`, `author`, `repo`(repository name), `begin` and `end` (creation
date range). The query returns the `openIssueCount` field, indicating the
number of open issues.

### Changed

Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ directories = "6"
git2 = "0.20"

graphql_client = "0.14"
jiff = "0.2"
jiff = { version = "0.2", features = ["serde"] }
regex = "1"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
Expand Down
8 changes: 2 additions & 6 deletions src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use sled::{Db, Tree};

use crate::{
github::{GitHubIssue, GitHubPullRequests},
graphql::{Issue, PullRequest},
graphql::{issue::Issue, pull_request::PullRequest},
};
const ISSUE_TREE_NAME: &str = "issues";
const PULL_REQUEST_TREE_NAME: &str = "pull_requests";
Expand Down Expand Up @@ -66,11 +66,7 @@ impl Database {
) -> Result<()> {
for item in resp {
let keystr: String = format!("{owner}/{name}#{}", item.number);
Database::insert(
&keystr,
(&item.title, &item.author, &item.closed_at),
&self.issue_tree,
)?;
Database::insert(&keystr, item, &self.issue_tree)?;
}
Ok(())
}
Expand Down
33 changes: 25 additions & 8 deletions src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
use graphql_client::{GraphQLQuery, QueryBody, Response as GraphQlResponse};
use jiff::Timestamp;
use reqwest::{Client, RequestBuilder, Response};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use tokio::time;
use tracing::error;

use crate::{database::Database, github::{
issues::IssuesRepositoryIssuesNodesAuthor::User as userName,
issues::{IssueState, IssuesRepositoryIssuesNodesAuthor::User as userName},
pull_requests::PullRequestsRepositoryPullRequestsNodesReviewRequestsNodesRequestedReviewer::User,
}, settings::Repository as RepoInfo};

Expand All @@ -18,15 +18,15 @@
const APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
const INIT_TIME: &str = "1992-06-05T00:00:00Z";

type DateTime = String;
type DateTime = Timestamp;

#[derive(GraphQLQuery)]
#[graphql(
schema_path = "src/github/schema.graphql",
query_path = "src/github/issues.graphql",
response_derives = "Debug"
response_derives = "Debug, Clone"
)]
struct Issues;
pub(crate) struct Issues;

#[derive(GraphQLQuery)]
#[graphql(
Expand All @@ -35,12 +35,22 @@
)]
struct PullRequests;

#[derive(Debug)]
#[allow(clippy::derivable_impls)]
impl Default for IssueState {
fn default() -> Self {
IssueState::OPEN
}
}

#[derive(Debug, Default, Serialize, Deserialize)]
pub(super) struct GitHubIssue {
pub(super) number: i64,
pub(super) title: String,
pub(super) author: String,
pub(super) closed_at: Option<DateTime>,
pub(super) created_at: DateTime,
pub(super) state: IssueState,
pub(super) assignees: Vec<String>,
}

#[derive(Debug)]
Expand Down Expand Up @@ -133,7 +143,7 @@
last: None,
before: None,
after: end_cur,
since: Some(since.to_string()),
since: Some(since.parse::<DateTime>()?),

Check warning on line 146 in src/github.rs

View check run for this annotation

Codecov / codecov/patch

src/github.rs#L146

Added line #L146 was not covered by tests
};
let resp_body: GraphQlResponse<issues::ResponseData> =
send_query::<Issues>(token, var).await?.json().await?;
Expand All @@ -147,11 +157,18 @@
if let userName(on_user) = author_ref {
author.clone_from(&on_user.login.clone());
}
let assignees =

Check warning on line 160 in src/github.rs

View check run for this annotation

Codecov / codecov/patch

src/github.rs#L160

Added line #L160 was not covered by tests
issue.assignees.nodes.as_ref().map_or(Vec::new(), |nodes| {
nodes.iter().flatten().map(|a| a.login.clone()).collect()

Check warning on line 162 in src/github.rs

View check run for this annotation

Codecov / codecov/patch

src/github.rs#L162

Added line #L162 was not covered by tests
});
issues.push(GitHubIssue {
number: issue.number,
title: issue.title.to_string(),
author,
closed_at: issue.closed_at.clone(),
closed_at: issue.closed_at,
created_at: issue.created_at,
state: issue.state.clone(),
assignees,

Check warning on line 171 in src/github.rs

View check run for this annotation

Codecov / codecov/patch

src/github.rs#L168-L171

Added lines #L168 - L171 were not covered by tests
});
}
if !repository.issues.page_info.has_next_page {
Expand Down
6 changes: 6 additions & 0 deletions src/github/issues.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ query Issues(
login
}
}
# TODO: #181
assignees(first: 10) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#173 (comment) 에서와 동일한 쟁점이 여기에도 있는 것 같습니다.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

특정 갯수로 제한하지 않고, 전부 다 가져올 수 있는 generic하면서 programatic한 방식을 도입하고, 이를 여러개의 PR에서 공통적으로 활용하는 것이 좋지 않을까합니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

페이지네이션 전략이 다양할 수 있어서 고민이 좀 필요할 것 같네요. 이슈를 따로 생성하고 한꺼번에 적용하는 게 좋을 것 같습니다. 당장 생각나는 방법은

  • 요청한 개수와 응답 받은 개수 비교해서 추가 요청하기
  • 초과할 가능성이 높은 필드만 추가로 확인해서 요청하기

등이 생각나네요.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요청한 개수와 응답 받은 개수 비교해서 추가 요청하기 방식을 취하면, 초과할 가능성이 높은 필드만 추가로 확인해서 요청하기 방식은 무조건 커버가 될 것으로 보입니다. 전자가 더 나은 방식이라고 생각합니다. 이슈를 생성해주실 수 있을까요?

한편, pagination이 결부되는 GraphQL field들은 이 PR에서 제거하고 리뷰&머지 하거나, 아니면 이 PR 자체를 pending 해두어야 할 것 같습니다. 아니면 이 부분을 TODO 로 주석에 표기해두고, 기술부채가 남지않도록 이를 챙기면 될 것 같습니다. 이 PR 진행방식은 저는 어떤 것이든 무관하다고 생각합니다. Jake께서 github-dashboard에서의 주요 2개 프로젝트 고려해서 정하면 될 것 같다고 생각합니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO 주석 추가했습니다.

nodes {
login
}
}
}
}
}
Expand Down
34 changes: 28 additions & 6 deletions src/graphql.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
mod issue;
mod pull_request;
pub(crate) mod issue;
pub(crate) mod issue_stat;
pub(crate) mod pull_request;

use std::fmt::Display;

use async_graphql::{
types::connection::{Connection, Edge, EmptyFields},
Context, EmptyMutation, EmptySubscription, MergedObject, OutputType, Result,
Context, EmptyMutation, EmptySubscription, InputValueError, InputValueResult, MergedObject,
OutputType, Result, Scalar, ScalarType, Value,
};
use base64::{engine::general_purpose, Engine as _};
use jiff::Timestamp;

pub(crate) use self::issue::Issue;
pub(crate) use self::pull_request::PullRequest;
use crate::database::Database;

/// The default page size for connections when neither `first` nor `last` is
Expand All @@ -21,10 +22,31 @@
///
/// This is exposed only for [`Schema`], and not used directly.
#[derive(Default, MergedObject)]
pub(crate) struct Query(issue::IssueQuery, pull_request::PullRequestQuery);
pub(crate) struct Query(
issue::IssueQuery,
pull_request::PullRequestQuery,
issue_stat::IssueStatQuery,
);

pub(crate) type Schema = async_graphql::Schema<Query, EmptyMutation, EmptySubscription>;

#[derive(Debug)]
pub(crate) struct DateTimeUtc(Timestamp);

#[Scalar]
impl ScalarType for DateTimeUtc {
fn parse(value: Value) -> InputValueResult<Self> {
match &value {
Value::String(s) => Ok(DateTimeUtc(s.parse()?)),
_ => Err(InputValueError::expected_type(value)),

Check warning on line 41 in src/graphql.rs

View check run for this annotation

Codecov / codecov/patch

src/graphql.rs#L41

Added line #L41 was not covered by tests
}
}

fn to_value(&self) -> Value {
Value::String(self.0.to_string())

Check warning on line 46 in src/graphql.rs

View check run for this annotation

Codecov / codecov/patch

src/graphql.rs#L45-L46

Added lines #L45 - L46 were not covered by tests
}
}

fn connect_cursor<T>(
select_vec: Vec<T>,
prev: bool,
Expand Down
76 changes: 33 additions & 43 deletions src/graphql/issue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ use std::fmt;
use anyhow::Context as AnyhowContext;
use async_graphql::{
connection::{query, Connection, EmptyFields},
Context, Object, Result, SimpleObject,
scalar, Context, Object, Result, SimpleObject,
};

use crate::database::{self, Database, TryFromKeyValue};
use crate::{
database::{self, Database, TryFromKeyValue},
github::{issues::IssueState, GitHubIssue},
graphql::DateTimeUtc,
};

scalar!(IssueState);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scalar! 를 활용 했을 때, IssueState가 github-dashboard 클라이언트에게 enum 방식으로 표현되나요 아니면 string으로 표현되나요? 만약 string 이라면 이것이 enum 방식으로 표현되는 방법이 있을까요?

Copy link
Contributor Author

@danbi2990 danbi2990 Jun 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Custom scalar 로 표현됩니다.
enum으로 표현하려면 enum을 직접 구현하고 변환 코드를 넣어야 됩니다.

Remote enum 이라는 게 있긴한데, 아래의 이유로 적용 불가능합니다.

  • 적용하면 에러 발생: GraphQL enums may only contain unit variants.
  • 원인은 IssueState 에 unit variants 가 아닌 Other(String)이 있기 때문
  • graphql-clientOther(String)하드코딩 되어있어서 제거할 수 없음


#[derive(SimpleObject)]
pub(crate) struct Issue {
Expand All @@ -15,19 +21,32 @@ pub(crate) struct Issue {
pub(crate) number: i32,
pub(crate) title: String,
pub(crate) author: String,
pub(crate) created_at: DateTimeUtc,
pub(crate) state: IssueState,
pub(crate) assignees: Vec<String>,
}

impl TryFromKeyValue for Issue {
fn try_from_key_value(key: &[u8], value: &[u8]) -> anyhow::Result<Self> {
let (owner, repo, number) = database::parse_key(key)
.with_context(|| format!("invalid key in database: {key:02x?}"))?;
let (title, author, _) = bincode::deserialize::<(String, String, Option<String>)>(value)?;
let GitHubIssue {
title,
author,
created_at,
state,
assignees,
..
} = bincode::deserialize::<GitHubIssue>(value)?;
let issue = Issue {
title,
author,
owner,
repo,
number: i32::try_from(number).unwrap_or(i32::MAX),
created_at: DateTimeUtc(created_at),
state,
assignees,
};
Ok(issue)
}
Expand Down Expand Up @@ -69,6 +88,15 @@ impl IssueQuery {
mod tests {
use crate::{github::GitHubIssue, graphql::TestSchema};

fn create_issues(n: usize) -> Vec<GitHubIssue> {
(1..=n)
.map(|i| GitHubIssue {
number: i64::try_from(i).unwrap(),
..Default::default()
})
.collect()
}

#[tokio::test]
async fn issues_empty() {
let schema = TestSchema::new();
Expand All @@ -89,26 +117,7 @@ mod tests {
#[tokio::test]
async fn issues_first() {
let schema = TestSchema::new();
let issues = vec![
GitHubIssue {
number: 1,
title: "issue 1".to_string(),
author: "author 1".to_string(),
closed_at: None,
},
GitHubIssue {
number: 2,
title: "issue 2".to_string(),
author: "author 2".to_string(),
closed_at: None,
},
GitHubIssue {
number: 3,
title: "issue 3".to_string(),
author: "author 3".to_string(),
closed_at: None,
},
];
let issues = create_issues(3);
schema.db.insert_issues(issues, "owner", "name").unwrap();

let query = r"
Expand Down Expand Up @@ -148,26 +157,7 @@ mod tests {
#[tokio::test]
async fn issues_last() {
let schema = TestSchema::new();
let issues = vec![
GitHubIssue {
number: 1,
title: "issue 1".to_string(),
author: "author 1".to_string(),
closed_at: None,
},
GitHubIssue {
number: 2,
title: "issue 2".to_string(),
author: "author 2".to_string(),
closed_at: None,
},
GitHubIssue {
number: 3,
title: "issue 3".to_string(),
author: "author 3".to_string(),
closed_at: None,
},
];
let issues = create_issues(3);
schema.db.insert_issues(issues, "owner", "name").unwrap();

let query = r"
Expand Down
Loading