use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FileChangeKind {
Added,
Modified,
Deleted,
Renamed,
Copied,
Changed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileChange {
pub path: String,
pub additions: u32,
pub deletions: u32,
pub change_kind: FileChangeKind,
#[serde(default)]
pub patch: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetailedCheck {
pub name: String,
pub workflow_name: Option<String>,
pub status: String,
pub conclusion: Option<String>,
pub duration_seconds: Option<u64>,
pub details_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetailedReview {
pub author: String,
pub state: crate::github::types::ReviewState,
pub body_markdown: String,
pub submitted_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewComment {
pub node_id: String,
pub author: String,
pub body_markdown: String,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub diff_hunk: Option<String>,
#[serde(default)]
pub original_commit_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewThread {
pub node_id: String,
pub path: String,
pub line: Option<u32>,
pub start_line: Option<u32>,
pub is_resolved: bool,
pub is_outdated: bool,
#[serde(default)]
pub diff_hunk: Option<String>,
pub comments: Vec<ReviewComment>,
}
impl ReviewThread {
pub fn originating_commit_sha(&self) -> Option<&str> {
self.comments.first()?.original_commit_id.as_deref()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueComment {
pub node_id: String,
pub author: String,
pub body_markdown: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrCommit {
pub sha: String,
pub short_sha: String,
pub headline: String,
pub author: String,
pub committed_at: DateTime<Utc>,
pub additions: u32,
pub deletions: u32,
pub changed_files: u32,
#[serde(default)]
pub check_state: Option<crate::github::types::CheckState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrDetail {
pub node_id: String,
pub repo: String,
pub number: u32,
pub title: String,
pub url: String,
pub author: String,
pub body_markdown: String,
pub base_ref: String,
pub head_ref: String,
pub head_oid: String,
pub is_draft: bool,
pub additions: u32,
pub deletions: u32,
pub changed_files_count: u32,
pub updated_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub merged: bool,
pub files: Vec<FileChange>,
pub check_runs: Vec<DetailedCheck>,
pub reviews: Vec<DetailedReview>,
pub review_threads: Vec<ReviewThread>,
pub issue_comments: Vec<IssueComment>,
pub commits: Vec<PrCommit>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueDetail {
pub node_id: String,
pub repo: String,
pub number: u32,
pub title: String,
pub url: String,
pub author: String,
pub body_markdown: String,
pub state: String,
pub updated_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub labels: Vec<crate::github::types::Label>,
pub assignees: Vec<String>,
pub comments: Vec<IssueComment>,
}
pub(super) const PR_DETAIL_QUERY: &str = r"
query PrDetail($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
id
number
title
url
isDraft
merged
body
createdAt
updatedAt
additions
deletions
changedFiles
baseRefName
headRefName
headRefOid
author { login }
files(first: 100) {
nodes {
path
additions
deletions
changeType
}
}
commits(last: 100) {
nodes {
commit {
oid
messageHeadline
additions
deletions
changedFilesIfAvailable
author {
name
date
}
statusCheckRollup {
state
contexts(first: 50) {
nodes {
... on CheckRun {
name
status
conclusion
startedAt
completedAt
detailsUrl
checkSuite {
workflowRun {
workflow { name }
}
}
}
... on StatusContext {
context
state
targetUrl
}
}
}
}
}
}
}
reviews(first: 50) {
nodes {
author { login }
state
body
submittedAt
}
}
reviewThreads(first: 100) {
nodes {
id
isResolved
isOutdated
path
line
originalLine
startLine
originalStartLine
comments(first: 20) {
nodes {
id
author { login }
body
createdAt
diffHunk
originalCommit { oid }
}
}
}
}
comments(first: 100) {
nodes {
id
author { login }
body
createdAt
}
}
}
}
}
";
pub(super) const ISSUE_DETAIL_QUERY: &str = r"
query IssueDetail($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $number) {
id
number
title
url
body
state
createdAt
updatedAt
author { login }
labels(first: 30) {
nodes {
name
color
}
}
assignees(first: 20) {
nodes { login }
}
comments(first: 100) {
nodes {
id
author { login }
body
createdAt
}
}
}
}
}
";
#[derive(Debug, Deserialize)]
pub(super) struct RawDetailData {
pub repository: Option<RawDetailRepository>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawDetailRepository {
pub pull_request: Option<RawPrDetail>,
pub issue: Option<RawIssueDetail>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawPrDetail {
pub id: String,
pub number: u32,
pub title: String,
pub url: String,
pub is_draft: bool,
pub merged: bool,
pub body: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub additions: u32,
pub deletions: u32,
pub changed_files: u32,
pub base_ref_name: String,
pub head_ref_name: String,
pub head_ref_oid: String,
pub author: Option<RawDetailActor>,
pub files: RawNodeList<RawFileNode>,
pub commits: RawNodeList<RawDetailCommitNode>,
pub reviews: RawNodeList<RawReviewNode>,
pub review_threads: RawNodeList<RawReviewThreadNode>,
pub comments: RawNodeList<RawCommentNode>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawIssueDetail {
pub id: String,
pub number: u32,
pub title: String,
pub url: String,
pub body: Option<String>,
pub state: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub author: Option<RawDetailActor>,
pub labels: RawNodeList<RawLabelNode>,
pub assignees: RawNodeList<RawDetailActor>,
pub comments: RawNodeList<RawCommentNode>,
}
#[derive(Debug, Deserialize)]
pub(super) struct RawDetailActor {
pub login: String,
}
#[derive(Debug, Deserialize)]
pub(super) struct RawNodeList<T> {
pub nodes: Vec<T>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawFileNode {
pub path: String,
pub additions: u32,
pub deletions: u32,
pub change_type: String,
}
#[derive(Debug, Deserialize)]
pub(super) struct RawDetailCommitNode {
pub commit: RawDetailCommit,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawDetailCommit {
pub oid: String,
pub message_headline: String,
pub additions: u32,
pub deletions: u32,
pub changed_files_if_available: Option<u32>,
pub author: Option<RawDetailCommitAuthor>,
pub status_check_rollup: Option<RawDetailRollup>,
}
#[derive(Debug, Deserialize)]
pub(super) struct RawDetailCommitAuthor {
pub name: Option<String>,
pub date: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub(super) struct RawDetailRollup {
pub state: crate::github::types::CheckState,
pub contexts: RawNodeList<RawDetailCheckContext>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(super) enum RawDetailCheckContext {
CheckRun(RawDetailCheckRun),
StatusContext(RawDetailStatusContext),
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawDetailCheckRun {
pub name: String,
pub status: String,
pub conclusion: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub details_url: Option<String>,
pub check_suite: Option<RawDetailCheckSuite>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawDetailStatusContext {
pub context: String,
pub state: String,
pub target_url: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawDetailCheckSuite {
pub workflow_run: Option<RawDetailWorkflowRun>,
}
#[derive(Debug, Deserialize)]
pub(super) struct RawDetailWorkflowRun {
pub workflow: Option<RawDetailWorkflow>,
}
#[derive(Debug, Deserialize)]
pub(super) struct RawDetailWorkflow {
pub name: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawReviewNode {
pub author: Option<RawDetailActor>,
pub state: crate::github::types::ReviewState,
pub body: Option<String>,
pub submitted_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawReviewThreadNode {
pub id: String,
pub is_resolved: bool,
pub is_outdated: bool,
pub path: String,
pub line: Option<u32>,
pub original_line: Option<u32>,
pub start_line: Option<u32>,
pub original_start_line: Option<u32>,
pub comments: RawNodeList<RawCommentNode>,
}
#[derive(Debug, Deserialize)]
pub(super) struct RawDetailOriginalCommit {
pub oid: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawCommentNode {
pub id: String,
pub author: Option<RawDetailActor>,
pub body: Option<String>,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub diff_hunk: Option<String>,
#[serde(default)]
pub original_commit: Option<RawDetailOriginalCommit>,
}
fn map_comment_node(c: RawCommentNode) -> IssueComment {
IssueComment {
node_id: c.id,
author: crate::github::author_or_deleted(c.author.map(|a| a.login)),
body_markdown: c.body.unwrap_or_default(),
created_at: c.created_at,
}
}
#[derive(Debug, Deserialize)]
pub(super) struct RawLabelNode {
pub name: String,
pub color: String,
}
fn parse_change_kind(s: &str) -> FileChangeKind {
match s {
"ADDED" => FileChangeKind::Added,
"MODIFIED" => FileChangeKind::Modified,
"DELETED" => FileChangeKind::Deleted,
"RENAMED" => FileChangeKind::Renamed,
"COPIED" => FileChangeKind::Copied,
_ => FileChangeKind::Changed,
}
}
#[allow(clippy::too_many_lines)]
pub(super) fn raw_pr_to_detail(repo: String, raw: RawPrDetail) -> PrDetail {
let files = raw
.files
.nodes
.into_iter()
.map(|f| FileChange {
path: f.path,
additions: f.additions,
deletions: f.deletions,
change_kind: parse_change_kind(&f.change_type),
patch: None,
})
.collect();
let commit_nodes: Vec<RawDetailCommitNode> = raw.commits.nodes;
let check_runs = commit_nodes
.last()
.and_then(|cn| cn.commit.status_check_rollup.as_ref())
.map(|rollup| {
rollup
.contexts
.nodes
.iter()
.map(|ctx| match ctx {
RawDetailCheckContext::CheckRun(cr) => {
let duration_seconds =
cr.started_at.zip(cr.completed_at).and_then(|(s, c)| {
let delta = c.signed_duration_since(s).num_seconds();
if delta >= 0 {
#[allow(clippy::cast_sign_loss)]
Some(delta as u64)
} else {
None
}
});
let workflow_name = cr
.check_suite
.as_ref()
.and_then(|cs| cs.workflow_run.as_ref())
.and_then(|wr| wr.workflow.as_ref())
.map(|w| w.name.clone());
DetailedCheck {
name: cr.name.clone(),
workflow_name,
status: cr.status.clone(),
conclusion: cr.conclusion.clone(),
duration_seconds,
details_url: cr.details_url.clone(),
}
}
RawDetailCheckContext::StatusContext(sc) => DetailedCheck {
name: sc.context.clone(),
workflow_name: None,
status: "COMPLETED".to_owned(),
conclusion: Some(sc.state.clone()),
duration_seconds: None,
details_url: sc.target_url.clone(),
},
})
.collect()
})
.unwrap_or_default();
let mut commits: Vec<PrCommit> = commit_nodes
.into_iter()
.filter_map(|cn| {
let c = cn.commit;
let author_node = c.author?;
let committed_at = author_node.date?;
let author = author_node.name.unwrap_or_else(|| "unknown".to_owned());
let short_sha: String = c.oid.chars().take(7).collect();
let check_state = c.status_check_rollup.map(|r| r.state);
Some(PrCommit {
sha: c.oid,
short_sha,
headline: c.message_headline,
author,
committed_at,
additions: c.additions,
deletions: c.deletions,
changed_files: c.changed_files_if_available.unwrap_or(0),
check_state,
})
})
.collect();
commits.sort_unstable_by_key(|commit| std::cmp::Reverse(commit.committed_at));
let reviews = raw
.reviews
.nodes
.into_iter()
.filter_map(|r| {
r.author.map(|a| DetailedReview {
author: a.login,
state: r.state,
body_markdown: r.body.unwrap_or_default(),
submitted_at: r.submitted_at,
})
})
.collect();
let review_threads = raw
.review_threads
.nodes
.into_iter()
.map(|t| {
let line = t.line.or(t.original_line);
let start_line = t.start_line.or(t.original_start_line);
let comments: Vec<ReviewComment> = t
.comments
.nodes
.into_iter()
.map(|c| ReviewComment {
node_id: c.id,
author: crate::github::author_or_deleted(c.author.map(|a| a.login)),
body_markdown: c.body.unwrap_or_default(),
created_at: c.created_at,
diff_hunk: c.diff_hunk,
original_commit_id: c.original_commit.map(|oc| oc.oid),
})
.collect();
let diff_hunk = comments.first().and_then(|c| c.diff_hunk.clone());
ReviewThread {
node_id: t.id,
path: t.path,
line,
start_line,
is_resolved: t.is_resolved,
is_outdated: t.is_outdated,
diff_hunk,
comments,
}
})
.collect();
let issue_comments = raw.comments.nodes.into_iter().map(map_comment_node).collect();
PrDetail {
node_id: raw.id,
repo,
number: raw.number,
title: raw.title,
url: raw.url,
author: crate::github::author_or_deleted(raw.author.map(|a| a.login)),
body_markdown: raw.body.unwrap_or_default(),
base_ref: raw.base_ref_name,
head_ref: raw.head_ref_name,
head_oid: raw.head_ref_oid,
is_draft: raw.is_draft,
additions: raw.additions,
deletions: raw.deletions,
changed_files_count: raw.changed_files,
updated_at: raw.updated_at,
created_at: raw.created_at,
merged: raw.merged,
files,
check_runs,
reviews,
review_threads,
issue_comments,
commits,
}
}
pub(super) fn raw_issue_to_detail(repo: String, raw: RawIssueDetail) -> IssueDetail {
let labels = raw
.labels
.nodes
.into_iter()
.map(|l| crate::github::types::Label { name: l.name, color: l.color })
.collect();
let assignees = raw.assignees.nodes.into_iter().map(|a| a.login).collect();
let comments = raw.comments.nodes.into_iter().map(map_comment_node).collect();
IssueDetail {
node_id: raw.id,
repo,
number: raw.number,
title: raw.title,
url: raw.url,
author: crate::github::author_or_deleted(raw.author.map(|a| a.login)),
body_markdown: raw.body.unwrap_or_default(),
state: raw.state,
updated_at: raw.updated_at,
created_at: raw.created_at,
labels,
assignees,
comments,
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::too_many_lines)]
mod tests {
use super::*;
use crate::github::query::GqlEnvelope;
type RawDetailResponse = GqlEnvelope<RawDetailData>;
fn pr_detail_fixture() -> serde_json::Value {
serde_json::json!({
"data": {
"repository": {
"pullRequest": {
"id": "PR_kwfixture",
"number": 42,
"title": "feat: add dark mode",
"url": "https://github.com/owner/repo/pull/42",
"isDraft": false,
"merged": false,
"body": "## Summary\nAdds dark mode support.",
"createdAt": "2024-01-01T10:00:00Z",
"updatedAt": "2024-01-02T12:00:00Z",
"additions": 150,
"deletions": 30,
"changedFiles": 5,
"baseRefName": "main",
"headRefName": "feat/dark-mode",
"headRefOid": "abc1234567890abcdef1234567890abcdef123456",
"author": { "login": "alice" },
"files": {
"nodes": [
{
"path": "src/theme.rs",
"additions": 100,
"deletions": 10,
"changeType": "MODIFIED"
},
{
"path": "src/new_file.rs",
"additions": 50,
"deletions": 0,
"changeType": "ADDED"
}
]
},
"commits": {
"nodes": [{
"commit": {
"oid": "abc1234567890abcdef1234567890abcdef123456",
"messageHeadline": "feat: add dark mode",
"additions": 150,
"deletions": 30,
"changedFilesIfAvailable": 5,
"author": {
"name": "Alice Dev",
"date": "2024-01-02T10:00:00Z"
},
"statusCheckRollup": {
"state": "SUCCESS",
"contexts": {
"nodes": [{
"name": "ci / build",
"status": "COMPLETED",
"conclusion": "SUCCESS",
"startedAt": "2024-01-02T11:00:00Z",
"completedAt": "2024-01-02T11:05:00Z",
"detailsUrl": "https://github.com/checks/1",
"checkSuite": {
"workflowRun": {
"workflow": { "name": "CI" }
}
}
}]
}
}
}
}]
},
"reviews": {
"nodes": [{
"author": { "login": "bob" },
"state": "APPROVED",
"body": "LGTM!",
"submittedAt": "2024-01-02T09:00:00Z"
}]
},
"reviewThreads": {
"nodes": [{
"id": "PRRT_kwfixture",
"isResolved": false,
"isOutdated": false,
"path": "src/theme.rs",
"line": 42,
"originalLine": 40,
"comments": {
"nodes": [
{
"id": "PRRC_kwfixture1",
"author": { "login": "bob" },
"body": "Consider extracting this constant.",
"createdAt": "2024-01-02T09:05:00Z"
},
{
"id": "PRRC_kwfixture2",
"author": { "login": "alice" },
"body": "Good point, will do.",
"createdAt": "2024-01-02T09:10:00Z"
}
]
}
}]
},
"comments": {
"nodes": [{
"id": "IC_kwfixture1",
"author": { "login": "carol" },
"body": "Nice work!",
"createdAt": "2024-01-02T10:00:00Z"
}]
}
}
}
}
})
}
fn issue_detail_fixture() -> serde_json::Value {
serde_json::json!({
"data": {
"repository": {
"issue": {
"id": "I_kwfixture",
"number": 7,
"title": "Bug: crash on empty config",
"url": "https://github.com/owner/repo/issues/7",
"body": "Reproducible with an empty `config.toml`.",
"state": "OPEN",
"createdAt": "2024-01-01T08:00:00Z",
"updatedAt": "2024-01-01T09:00:00Z",
"author": { "login": "dave" },
"labels": {
"nodes": [
{ "name": "bug", "color": "ee0701" }
]
},
"assignees": {
"nodes": [{ "login": "alice" }]
},
"comments": {
"nodes": [{
"id": "IC_kwfixture2",
"author": { "login": "bob" },
"body": "I can reproduce this too.",
"createdAt": "2024-01-01T08:30:00Z"
}]
}
}
}
}
})
}
#[test]
fn pr_detail_deserialises_correctly() {
let json = pr_detail_fixture();
let raw: RawDetailResponse = serde_json::from_value(json).expect("deserialise");
let repo_raw = raw
.data
.expect("data")
.repository
.expect("repository")
.pull_request
.expect("pull_request");
let detail = raw_pr_to_detail("owner/repo".to_owned(), repo_raw);
assert_eq!(detail.number, 42);
assert_eq!(detail.author, "alice");
assert_eq!(detail.base_ref, "main");
assert_eq!(detail.head_ref, "feat/dark-mode");
assert_eq!(detail.additions, 150);
assert_eq!(detail.changed_files_count, 5);
assert!(!detail.merged);
}
#[test]
fn issue_detail_deserialises_correctly() {
let json = issue_detail_fixture();
let raw: RawDetailResponse = serde_json::from_value(json).expect("deserialise");
let repo_raw =
raw.data.expect("data").repository.expect("repository").issue.expect("issue");
let detail = raw_issue_to_detail("owner/repo".to_owned(), repo_raw);
assert_eq!(detail.number, 7);
assert_eq!(detail.state, "OPEN");
assert_eq!(detail.assignees, vec!["alice"]);
assert_eq!(detail.labels.len(), 1);
assert_eq!(detail.labels[0].name, "bug");
assert_eq!(detail.comments.len(), 1);
}
#[test]
fn file_change_kind_all_variants() {
assert_eq!(parse_change_kind("ADDED"), FileChangeKind::Added);
assert_eq!(parse_change_kind("MODIFIED"), FileChangeKind::Modified);
assert_eq!(parse_change_kind("DELETED"), FileChangeKind::Deleted);
assert_eq!(parse_change_kind("RENAMED"), FileChangeKind::Renamed);
assert_eq!(parse_change_kind("COPIED"), FileChangeKind::Copied);
assert_eq!(parse_change_kind("CHANGED"), FileChangeKind::Changed);
}
#[test]
fn file_change_kind_unknown_falls_back() {
assert_eq!(parse_change_kind("FUTURE_VARIANT"), FileChangeKind::Changed);
}
#[test]
fn check_run_duration_computed() {
let json = pr_detail_fixture();
let raw: RawDetailResponse = serde_json::from_value(json).expect("deserialise");
let repo_raw = raw
.data
.expect("data")
.repository
.expect("repository")
.pull_request
.expect("pull_request");
let detail = raw_pr_to_detail("owner/repo".to_owned(), repo_raw);
assert_eq!(detail.check_runs.len(), 1);
assert_eq!(detail.check_runs[0].duration_seconds, Some(300));
assert_eq!(detail.check_runs[0].workflow_name.as_deref(), Some("CI"));
}
#[test]
fn check_run_duration_none_when_incomplete() {
let json = serde_json::json!({
"id": "PR_kwduration",
"number": 1, "title": "t", "url": "u", "isDraft": false, "merged": false,
"body": null, "createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"additions": 0, "deletions": 0, "changedFiles": 0,
"baseRefName": "main", "headRefName": "feat",
"headRefOid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"author": null,
"files": { "nodes": [] },
"commits": { "nodes": [{
"commit": {
"oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"messageHeadline": "wip",
"additions": 0,
"deletions": 0,
"changedFilesIfAvailable": null,
"author": { "name": "dev", "date": "2024-01-01T00:00:00Z" },
"statusCheckRollup": { "state": "PENDING", "contexts": { "nodes": [{
"name": "build",
"status": "IN_PROGRESS",
"conclusion": null,
"startedAt": "2024-01-01T00:00:00Z",
"completedAt": null,
"detailsUrl": null,
"checkSuite": null
}] } }
}
}] },
"reviews": { "nodes": [] },
"reviewThreads": { "nodes": [] },
"comments": { "nodes": [] }
});
let raw: RawPrDetail = serde_json::from_value(json).expect("deserialise");
let detail = raw_pr_to_detail("owner/repo".to_owned(), raw);
assert_eq!(detail.check_runs[0].duration_seconds, None);
}
#[test]
fn review_thread_preserves_comment_order() {
let json = pr_detail_fixture();
let raw: RawDetailResponse = serde_json::from_value(json).expect("deserialise");
let repo_raw = raw
.data
.expect("data")
.repository
.expect("repository")
.pull_request
.expect("pull_request");
let detail = raw_pr_to_detail("owner/repo".to_owned(), repo_raw);
assert_eq!(detail.review_threads.len(), 1);
let thread = &detail.review_threads[0];
assert_eq!(thread.comments.len(), 2);
assert_eq!(thread.comments[0].author, "bob");
assert_eq!(thread.comments[1].author, "alice");
assert_eq!(thread.line, Some(42));
}
}