diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e5412..1cd1830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm date range). The query returns the following fields: - `openIssueCount`: The number of open issues. - `resolvedIssueSizeDistribution`: The distribution of sizes for resolved issues. + - `avgResolutionDays`: The average resolution time in days for resolved issues. - Added additional fields to the `issues` GraphQL query, providing detailed information such as comments, labels, related sub-issues, linked pull requests, issue descriptions, timestamps, and project-related metadata. diff --git a/src/api.rs b/src/api.rs index cbe0b81..5bf66d3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -18,8 +18,12 @@ use jiff::Timestamp; pub(crate) use self::discussion::Discussion; use crate::database::Database; -/// The default page size for connections when neither `first` nor `last` is -/// provided. +// The title of the github project. +pub(crate) const TODO_LIST_PROJECT_TITLE: &str = "to-do list"; +// The status name of the "Done" column in the github project. +pub(crate) const TODO_LIST_STATUS_DONE: &str = "Done"; + +/// The default page size for connections when neither `first` nor `last` is provided. const DEFAULT_PAGE_SIZE: usize = 100; /// A set of queries defined in the schema. diff --git a/src/api/issue.rs b/src/api/issue.rs index 0f06324..1cca49c 100644 --- a/src/api/issue.rs +++ b/src/api/issue.rs @@ -8,8 +8,7 @@ use async_graphql::{ use serde::{Deserialize, Serialize}; use crate::{ - api, - api::DateTimeUtc, + api::{self, DateTimeUtc, TODO_LIST_PROJECT_TITLE, TODO_LIST_STATUS_DONE}, database::{self, Database, GitHubIssue, TryFromKeyValue}, outbound::issues::{IssueState, PullRequestState}, }; @@ -112,9 +111,6 @@ pub(crate) struct PullRequestRef { pub(crate) url: String, } -pub(super) const TODO_LIST_PROJECT_TITLE: &str = "to-do list"; -pub(super) const TODO_LIST_STATUS_DONE: &str = "Done"; - impl Issue { /// We define an issue is "Resolved" if and only if /// - Status of the issue is "Closed" AND diff --git a/src/api/issue_stat.rs b/src/api/issue_stat.rs index 6079a28..93806ca 100644 --- a/src/api/issue_stat.rs +++ b/src/api/issue_stat.rs @@ -1,9 +1,12 @@ use std::collections::BTreeMap; +use anyhow::Context as _; use async_graphql::{Context, Enum, InputObject, Object, Result, SimpleObject}; +use jiff::{SpanTotal, Unit}; +use num_traits::ToPrimitive; use crate::{ - api::{issue::Issue, DateTimeUtc}, + api::{issue::Issue, DateTimeUtc, TODO_LIST_PROJECT_TITLE}, database::Iter, outbound::issues::IssueState, Database, @@ -91,6 +94,9 @@ struct IssueStat { /// The distribution of resolved issues by size. resolved_issue_size_distribution: Vec, + + /// The average resolution time in days for resolved issues. + avg_resolution_days: Option, } #[Object] @@ -122,7 +128,7 @@ impl IssueStatQuery { .project_items .nodes .iter() - .find(|item| item.project_title == super::issue::TODO_LIST_PROJECT_TITLE) + .find(|item| item.project_title == TODO_LIST_PROJECT_TITLE) .and_then(|item| item.todo_size.as_deref()) .unwrap_or("None"); *acc.entry(size_str.into()).or_insert(0) += 1; @@ -132,10 +138,47 @@ impl IssueStatQuery { .map(|(size, count)| IssueSizeCount { size, count }) .collect(); + let resolution_days: Vec = resolved_issues + .iter() + .filter_map(|issue| { + let closed_at = issue.closed_at?; + let created_at = issue.created_at; + + let project_item = issue + .project_items + .nodes + .iter() + .find(|p| p.project_title == TODO_LIST_PROJECT_TITLE)?; + + let pending_days = project_item.todo_pending_days.unwrap_or(0.0); + + let span = created_at.0.until(closed_at.0).ok()?; + let resolution_days = span + .total(SpanTotal::from(Unit::Day).days_are_24_hours()) + .ok()?; + let result_days = resolution_days - pending_days; + + Some(f64::max(result_days, 0.0)) + }) + .collect(); + + let avg_resolution_days = if resolution_days.is_empty() { + None + } else { + Some( + resolution_days.iter().sum::() + / resolution_days + .len() + .to_f64() + .context("Failed to convert usize to f64")?, + ) + }; + Ok(IssueStat { open_issue_count, resolved_issue_count, resolved_issue_size_distribution, + avg_resolution_days, }) } } @@ -145,8 +188,7 @@ mod tests { use jiff::Timestamp; use crate::{ - api::issue::{TODO_LIST_PROJECT_TITLE, TODO_LIST_STATUS_DONE}, - api::TestSchema, + api::{TestSchema, TODO_LIST_PROJECT_TITLE, TODO_LIST_STATUS_DONE}, database::issue::{GitHubIssue, GitHubProjectV2Item, GitHubProjectV2ItemConnection}, outbound::issues::IssueState, }; @@ -607,7 +649,7 @@ mod tests { let query = r" { issueStat(filter: {}) { - resolvedIssueSizeDistribution { + resolvedIssueSizeDistribution { size count } @@ -624,4 +666,68 @@ mod tests { ]) ); } + + #[tokio::test] + async fn avg_resolution_days() { + let schema = TestSchema::new(); + let owner = "aicers"; + let repo = "github-dashboard-server"; + let mut resolved_issues = create_resolved_issues(1..=2); + + // Issue 1: 10 days resolution, 2 pending days. Net: 8 days. + resolved_issues[0].created_at = parse("2025-01-01T00:00:00Z"); + resolved_issues[0].closed_at = Some(parse("2025-01-11T00:00:00Z")); + resolved_issues[0].project_items.nodes[0].todo_pending_days = Some(2.0); + + // Issue 2: 5 days resolution, 1 pending day. Net: 4 days. + resolved_issues[1].created_at = parse("2025-01-01T00:00:00Z"); + resolved_issues[1].closed_at = Some(parse("2025-01-06T00:00:00Z")); + resolved_issues[1].project_items.nodes[0].todo_pending_days = Some(1.0); + + schema + .db + .insert_issues(resolved_issues, owner, repo) + .unwrap(); + + let query = r" + { + issueStat(filter: {}) { + avgResolutionDays + } + }"; + let data = schema.execute(query).await.data.into_json().unwrap(); + // Average of 8 and 4 is 6. + assert_eq!(data["issueStat"]["avgResolutionDays"], 6.0); + } + + #[tokio::test] + async fn avg_resolution_days_is_not_negative() { + let schema = TestSchema::new(); + let owner = "aicers"; + let repo = "github-dashboard-server"; + let mut resolved_issues = create_resolved_issues(1..=1); + + // Create an issue where the pending days (10 days) exceed the resolution days (5 days). + // In this case, the net resolution days should be 0, not negative. + // resolution_days (5.0) - pending_days (10.0) = -5.0 + resolved_issues[0].created_at = parse("2025-01-01T00:00:00Z"); + resolved_issues[0].closed_at = Some(parse("2025-01-06T00:00:00Z")); + resolved_issues[0].project_items.nodes[0].todo_pending_days = Some(10.0); + + schema + .db + .insert_issues(resolved_issues, owner, repo) + .unwrap(); + + let query = r" + { + issueStat(filter: {}) { + avgResolutionDays + } + }"; + let data = schema.execute(query).await.data.into_json().unwrap(); + + // The average resolution days should be 0.0, not negative. + assert_eq!(data["issueStat"]["avgResolutionDays"], 0.0); + } }