use agentic_tools_core::fmt::TextFormat;
use agentic_tools_core::fmt::TextOptions;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::sync::OnceLock;
#[derive(
Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq, clap::ValueEnum,
)]
#[serde(rename_all = "lowercase")]
pub enum CommentSourceType {
Robot,
Human,
#[default]
All,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReviewComment {
pub id: u64,
pub user: String,
pub is_bot: bool,
pub body: String,
pub path: String,
pub line: Option<u64>,
pub side: Option<String>,
pub created_at: String,
pub updated_at: String,
pub html_url: String,
pub pull_request_review_id: Option<u64>,
pub in_reply_to_id: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PrSummary {
pub number: u64,
pub title: String,
pub author: String,
pub state: String,
pub created_at: String,
pub updated_at: String,
pub comment_count: u32,
pub review_comment_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReviewCommentList {
pub owner: String,
pub repo: String,
pub pr_number: u64,
pub pr_url: String,
pub comments: Vec<ReviewComment>,
pub shown_threads: usize,
pub total_threads: usize,
pub has_more: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Thread {
pub parent: ReviewComment,
pub replies: Vec<ReviewComment>,
pub is_resolved: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PrSummaryList {
pub owner: String,
pub repo: String,
pub state: String,
pub prs: Vec<PrSummary>,
pub shown_prs: usize,
pub total_prs: usize,
pub has_more: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FormatOptions {
pub show_ids: bool,
pub show_urls: bool,
pub show_dates: bool, pub show_review_ids: bool, pub show_counts: bool, pub show_author: bool, }
impl Default for FormatOptions {
fn default() -> Self {
Self {
show_ids: true,
show_urls: false,
show_dates: false,
show_review_ids: false,
show_counts: false,
show_author: false,
}
}
}
static FORMAT_OPTIONS: OnceLock<FormatOptions> = OnceLock::new();
impl FormatOptions {
pub fn from_env() -> Self {
let raw = std::env::var("PR_COMMENTS_EXTRAS").unwrap_or_default();
Self::from_csv(&raw)
}
pub fn from_csv(csv: &str) -> Self {
let mut opts = FormatOptions::default();
for flag in csv
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
{
match flag.as_str() {
"id" | "ids" => opts.show_ids = true,
"noid" | "no_ids" => opts.show_ids = false,
"url" | "urls" => opts.show_urls = true,
"date" | "dates" | "time" | "times" => opts.show_dates = true,
"review" | "review_id" | "review_ids" => opts.show_review_ids = true,
"count" | "counts" => opts.show_counts = true,
"author" | "authors" => opts.show_author = true,
_ => {}
}
}
opts
}
pub fn get() -> &'static FormatOptions {
FORMAT_OPTIONS.get_or_init(Self::from_env)
}
}
pub fn group_by_path(comments: &[ReviewComment]) -> BTreeMap<&str, Vec<&ReviewComment>> {
let mut map: BTreeMap<&str, Vec<&ReviewComment>> = BTreeMap::new();
for c in comments {
map.entry(&c.path).or_default().push(c);
}
map
}
pub fn compress_side(side: Option<&str>) -> &'static str {
match side {
Some(s) if s.eq_ignore_ascii_case("RIGHT") => "R",
Some(s) if s.eq_ignore_ascii_case("LEFT") => "L",
_ => "-",
}
}
pub fn format_legend() -> &'static str {
"Legend: L = old (LEFT), R = new (RIGHT), - = unknown"
}
pub fn indent_multiline(s: &str, indent: &str) -> String {
let mut out = String::new();
for (i, line) in s.lines().enumerate() {
if i == 0 {
out.push_str(line);
} else {
out.push('\n');
out.push_str(indent);
out.push_str(line);
}
}
out
}
fn fmt_header(title: &str) -> String {
title.to_string()
}
fn fmt_user(user: &str) -> &str {
if user.is_empty() { "<unknown>" } else { user }
}
fn fmt_ts(ts: &str) -> &str {
ts
}
impl TextFormat for ReviewCommentList {
fn fmt_text(&self, _opts: &TextOptions) -> String {
self.fmt_text_with_options(FormatOptions::get())
}
}
impl ReviewCommentList {
pub fn fmt_text_with_options(&self, opts: &FormatOptions) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"{}",
fmt_header(&format!(
"Review comments for {}/{}#{}:",
self.owner, self.repo, self.pr_number
))
);
let _ = writeln!(out, "PR: {}", self.pr_url);
let _ = writeln!(
out,
"Threads shown: {} of {}",
self.shown_threads, self.total_threads
);
if self.comments.is_empty() {
let _ = writeln!(out, "No matching review comment threads.");
let _ = writeln!(out, "{}", self.pagination_footer());
return out.trim_end().to_string();
}
let _ = writeln!(out, "{}", format_legend());
let grouped = group_by_path(&self.comments);
for (path, comments) in grouped {
let _ = writeln!(out, "\n{}", path);
let mut replies_by_parent: std::collections::BTreeMap<u64, Vec<&ReviewComment>> =
std::collections::BTreeMap::new();
for c in comments.iter().filter(|c| c.in_reply_to_id.is_some()) {
if let Some(pid) = c.in_reply_to_id {
replies_by_parent.entry(pid).or_default().push(c);
}
}
for c in comments {
if c.in_reply_to_id.is_some() {
continue; }
let side = compress_side(c.side.as_deref());
let line_disp = c.line.map(|n| n.to_string()).unwrap_or_else(|| "?".into());
let mut head = format!(" [{} {}] {}", line_disp, side, fmt_user(&c.user));
if opts.show_ids {
head.push_str(&format!(" #{}", c.id));
}
if opts.show_review_ids
&& let Some(rid) = c.pull_request_review_id
{
head.push_str(&format!(" (review:{})", rid));
}
if opts.show_dates {
head.push_str(&format!(" @{}", fmt_ts(&c.created_at)));
}
let _ = writeln!(out, "{}", head);
let body = indent_multiline(&c.body, " ");
let _ = writeln!(out, " {}", body);
let _ = writeln!(out, " Thread: {}", c.html_url);
if let Some(replies) = replies_by_parent.get(&c.id) {
for r in replies {
let side_r = compress_side(r.side.as_deref());
let line_r = r.line.map(|n| n.to_string()).unwrap_or_else(|| "?".into());
let mut head_r =
format!(" ↳ [{} {}] {}", line_r, side_r, fmt_user(&r.user));
if opts.show_ids {
head_r.push_str(&format!(" #{}", r.id));
}
if opts.show_review_ids
&& let Some(rid) = r.pull_request_review_id
{
head_r.push_str(&format!(" (review:{})", rid));
}
if opts.show_urls {
head_r.push_str(&format!(" {}", r.html_url));
}
if opts.show_dates {
head_r.push_str(&format!(" @{}", fmt_ts(&r.created_at)));
}
let _ = writeln!(out, "{}", head_r);
let body_r = indent_multiline(&r.body, " ");
let _ = writeln!(out, " {}", body_r);
}
}
}
}
let _ = writeln!(out, "\n{}", self.pagination_footer());
out.trim_end().to_string()
}
fn pagination_footer(&self) -> String {
if self.has_more {
format!(
"(more results — showing {} of {} threads; call gh_get_comments again with same params for next page)",
self.shown_threads, self.total_threads
)
} else {
format!(
"(complete — showing {} of {} threads; stop here; another identical gh_get_comments call restarts from page 1)",
self.shown_threads, self.total_threads
)
}
}
}
impl TextFormat for PrSummaryList {
fn fmt_text(&self, _opts: &TextOptions) -> String {
self.fmt_text_with_options(FormatOptions::get())
}
}
impl PrSummaryList {
pub fn fmt_text_with_options(&self, opts: &FormatOptions) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"{}",
fmt_header(&format!(
"Pull requests for {}/{} (state={}):",
self.owner, self.repo, self.state
))
);
let _ = writeln!(out, "PRs shown: {} of {}", self.shown_prs, self.total_prs);
if self.prs.is_empty() {
let _ = writeln!(out, "No matching pull requests.");
let _ = writeln!(out, "{}", self.pagination_footer());
return out.trim_end().to_string();
}
for pr in &self.prs {
let mut line = format!("#{} {} — {}", pr.number, pr.state, pr.title);
if opts.show_author {
line.push_str(&format!(" (by {})", pr.author));
}
if opts.show_counts {
line.push_str(&format!(
" [comments={}, review_comments={}]",
pr.comment_count, pr.review_comment_count
));
}
if opts.show_dates {
line.push_str(&format!(" @{}", fmt_ts(&pr.updated_at)));
}
let _ = writeln!(out, "{}", line);
}
let _ = writeln!(out, "\n{}", self.pagination_footer());
out.trim_end().to_string()
}
fn pagination_footer(&self) -> String {
if self.has_more {
format!(
"(more results — showing {} of {} pull requests; call gh_get_prs again with same params for next page)",
self.shown_prs, self.total_prs
)
} else {
format!(
"(complete — showing {} of {} pull requests; stop here; another identical gh_get_prs call restarts from page 1)",
self.shown_prs, self.total_prs
)
}
}
}
impl TextFormat for ReviewComment {
fn fmt_text(&self, _opts: &TextOptions) -> String {
let opts = FormatOptions::get();
let mut out = String::new();
let _ = writeln!(out, "{}", fmt_header("Reply posted:"));
let side = compress_side(self.side.as_deref());
let line_disp = self
.line
.map(|n| n.to_string())
.unwrap_or_else(|| "?".into());
let mut head = format!(
"{} [{} {}] {}",
self.path,
line_disp,
side,
fmt_user(&self.user)
);
if opts.show_ids {
head.push_str(&format!(" #{}", self.id));
}
if opts.show_review_ids
&& let Some(rid) = self.pull_request_review_id
{
head.push_str(&format!(" (review:{})", rid));
}
if opts.show_urls {
head.push_str(&format!(" {}", self.html_url));
}
if opts.show_dates {
head.push_str(&format!(" @{}", fmt_ts(&self.created_at)));
}
let _ = writeln!(out, "{}", head);
let body = indent_multiline(&self.body, " ");
let _ = writeln!(out, " {}", body);
out
}
}
impl From<octocrab::models::pulls::Comment> for ReviewComment {
fn from(comment: octocrab::models::pulls::Comment) -> Self {
let (user, is_bot) = match comment.user {
Some(u) => {
let is_bot = u.r#type == "Bot" || u.login.ends_with("[bot]");
(u.login, is_bot)
}
None => (String::new(), false),
};
Self {
id: comment.id.0,
user,
is_bot,
body: comment.body,
path: comment.path,
line: comment.line,
side: comment.side,
created_at: comment.created_at.to_rfc3339(),
updated_at: comment.updated_at.to_rfc3339(),
html_url: comment.html_url,
pull_request_review_id: comment.pull_request_review_id.map(|id| id.0),
in_reply_to_id: comment.in_reply_to_id.map(|id| id.0),
}
}
}
impl ReviewComment {
pub fn from_review_comment(comment: octocrab::models::pulls::ReviewComment) -> Self {
let (user, is_bot) = match comment.user {
Some(u) => {
let is_bot = u.r#type == "Bot" || u.login.ends_with("[bot]");
(u.login, is_bot)
}
None => (String::new(), false),
};
let side = comment.side.map(|s| match s {
octocrab::models::pulls::Side::Left => "LEFT".to_string(),
octocrab::models::pulls::Side::Right => "RIGHT".to_string(),
_ => "UNKNOWN".to_string(),
});
Self {
id: comment.id.0,
user,
is_bot,
body: comment.body,
path: comment.path,
line: comment.line,
side,
created_at: comment.created_at.to_rfc3339(),
updated_at: comment.updated_at.to_rfc3339(),
html_url: comment.html_url.to_string(),
pull_request_review_id: comment.pull_request_review_id.map(|id| id.0),
in_reply_to_id: comment.in_reply_to_id.map(|id| id.0),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphQLResponse<T> {
pub data: Option<T>,
pub errors: Option<Vec<GraphQLError>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphQLError {
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullRequestData {
pub repository: Repository,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Repository {
#[serde(rename = "pullRequest")]
pub pull_request: PullRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullRequest {
#[serde(rename = "reviewThreads")]
pub review_threads: ReviewThreadConnection,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewThreadConnection {
pub nodes: Vec<ReviewThread>,
#[serde(rename = "pageInfo")]
pub page_info: PageInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewThread {
pub id: String,
#[serde(rename = "isResolved")]
pub is_resolved: bool,
pub comments: ReviewThreadCommentConnection,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewThreadCommentConnection {
pub nodes: Vec<ReviewThreadComment>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewThreadComment {
pub id: String,
#[serde(rename = "databaseId")]
pub database_id: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageInfo {
#[serde(rename = "hasNextPage")]
pub has_next_page: bool,
#[serde(rename = "endCursor")]
pub end_cursor: Option<String>,
}
#[cfg(test)]
mod format_options_tests {
use super::FormatOptions;
#[test]
fn parses_empty_flags() {
let o = FormatOptions::from_csv("");
assert!(
o.show_ids
&& !o.show_urls
&& !o.show_dates
&& !o.show_review_ids
&& !o.show_counts
&& !o.show_author
);
}
#[test]
fn parses_known_flags_with_synonyms() {
let o = FormatOptions::from_csv("id, url, dates, review, counts, author");
assert!(
o.show_ids
&& o.show_urls
&& o.show_dates
&& o.show_review_ids
&& o.show_counts
&& o.show_author
);
let o2 = FormatOptions::from_csv("ids, urls, times, review_id, count, authors");
assert!(
o2.show_ids
&& o2.show_urls
&& o2.show_dates
&& o2.show_review_ids
&& o2.show_counts
&& o2.show_author
);
}
#[test]
fn parses_noid_and_precedence() {
let o = FormatOptions::from_csv("noid");
assert!(!o.show_ids);
let o2 = FormatOptions::from_csv("no_ids");
assert!(!o2.show_ids);
let o3 = FormatOptions::from_csv("id,noid");
assert!(!o3.show_ids);
let o4 = FormatOptions::from_csv("noid,id");
assert!(o4.show_ids);
}
}
#[cfg(test)]
mod mcp_format_tests {
use super::*;
#[expect(clippy::too_many_arguments, reason = "Test helper keeps cases concise")]
fn sample_review(
id: u64,
path: &str,
line: Option<u64>,
side: Option<&str>,
user: &str,
body: &str,
html_url: &str,
in_reply_to_id: Option<u64>,
) -> ReviewComment {
ReviewComment {
id,
user: user.into(),
is_bot: false,
body: body.into(),
path: path.into(),
line,
side: side.map(|s| s.into()),
created_at: "2025-01-01T00:00:00Z".into(),
updated_at: "2025-01-01T00:00:00Z".into(),
html_url: html_url.into(),
pull_request_review_id: Some(42),
in_reply_to_id,
}
}
fn sample_review_list(comments: Vec<ReviewComment>, has_more: bool) -> ReviewCommentList {
let shown_threads = comments
.iter()
.filter(|c| c.in_reply_to_id.is_none())
.count();
let total_threads = if has_more {
shown_threads.saturating_add(1)
} else {
shown_threads
};
ReviewCommentList {
owner: "octo".into(),
repo: "hello-world".into(),
pr_number: 123,
pr_url: "https://github.com/octo/hello-world/pull/123".into(),
comments,
shown_threads,
total_threads,
has_more,
message: has_more.then(|| {
format!(
"Showing {} out of {} threads. Call gh_get_comments again for more.",
shown_threads, total_threads
)
}),
}
}
fn sample_pr_summary_list(prs: Vec<PrSummary>, has_more: bool) -> PrSummaryList {
let shown_prs = prs.len();
let total_prs = if has_more {
shown_prs.saturating_add(1)
} else {
shown_prs
};
PrSummaryList {
owner: "octo".into(),
repo: "hello-world".into(),
state: "open".into(),
prs,
shown_prs,
total_prs,
has_more,
message: has_more.then(|| {
format!(
"Showing {} out of {} pull requests. Call gh_get_prs again for more.",
shown_prs, total_prs
)
}),
}
}
#[test]
fn group_by_path_groups_and_orders() {
let cs = vec![
sample_review(
1,
"a.rs",
Some(1),
Some("RIGHT"),
"u",
"x",
"https://x/1",
None,
),
sample_review(
2,
"b.rs",
Some(2),
Some("LEFT"),
"u",
"y",
"https://x/2",
None,
),
sample_review(3, "a.rs", None, None, "u2", "z", "https://x/3", None),
];
let g = group_by_path(&cs);
let keys: Vec<_> = g.keys().cloned().collect();
assert_eq!(keys, vec!["a.rs", "b.rs"]);
assert_eq!(g["a.rs"].len(), 2);
assert_eq!(g["b.rs"].len(), 1);
}
#[test]
fn side_compression() {
assert_eq!(compress_side(Some("RIGHT")), "R");
assert_eq!(compress_side(Some("LEFT")), "L");
assert_eq!(compress_side(Some("right")), "R");
assert_eq!(compress_side(None), "-");
}
#[test]
fn indent_multiline_preserves_and_indents() {
let s = "line1\nline2\nline3";
let out = indent_multiline(s, " ");
assert!(out.starts_with("line1"));
assert!(out.contains("\n line2"));
assert!(out.ends_with("\n line3"));
}
#[test]
fn format_review_comment_list_shows_header_thread_url_and_completion_footer() {
let list = sample_review_list(
vec![
sample_review(
1,
"src/lib.rs",
Some(12),
Some("RIGHT"),
"alice",
"Body A\nMore",
"https://example.com/review/1",
None,
),
sample_review(
2,
"src/lib.rs",
Some(42),
Some("LEFT"),
"bob",
"Body B",
"https://example.com/review/2",
None,
),
],
false,
);
let text = list.fmt_text_with_options(&FormatOptions::default());
assert!(text.contains("Review comments for octo/hello-world#123:"));
assert!(text.contains("PR: https://github.com/octo/hello-world/pull/123"));
assert!(text.contains("Threads shown: 2 of 2"));
assert!(text.contains("Legend:"));
assert!(text.contains("src/lib.rs"));
assert!(text.contains("[12 R] alice"));
assert!(text.contains("[42 L] bob"));
assert!(text.contains("Body A"));
assert!(text.contains("\n More"));
assert!(text.contains("Thread: https://example.com/review/1"));
assert!(text.contains("#1")); assert!(text.contains("complete — showing 2 of 2 threads"));
assert!(text.contains("restarts from page 1"));
}
#[test]
fn format_review_comment_list_shows_more_results_footer() {
let list = sample_review_list(
vec![sample_review(
1,
"src/lib.rs",
Some(12),
Some("RIGHT"),
"alice",
"Body A",
"https://example.com/review/1",
None,
)],
true,
);
let text = list.fmt_text_with_options(&FormatOptions::default());
assert!(text.contains("Threads shown: 1 of 2"));
assert!(text.contains("more results — showing 1 of 2 threads"));
assert!(text.contains("call gh_get_comments again with same params"));
}
#[test]
fn format_review_comment_list_hides_reply_urls_by_default() {
let list = sample_review_list(
vec![
sample_review(
1,
"src/lib.rs",
Some(12),
Some("RIGHT"),
"alice",
"Parent",
"https://example.com/review/1",
None,
),
sample_review(
2,
"src/lib.rs",
Some(12),
Some("RIGHT"),
"bob",
"Reply",
"https://example.com/review/2",
Some(1),
),
],
false,
);
let text = list.fmt_text_with_options(&FormatOptions::default());
assert!(text.contains("Thread: https://example.com/review/1"));
assert!(!text.contains("https://example.com/review/2"));
}
#[test]
fn format_review_comment_list_shows_reply_urls_when_enabled() {
let list = sample_review_list(
vec![
sample_review(
1,
"src/lib.rs",
Some(12),
Some("RIGHT"),
"alice",
"Parent",
"https://example.com/review/1",
None,
),
sample_review(
2,
"src/lib.rs",
Some(12),
Some("RIGHT"),
"bob",
"Reply",
"https://example.com/review/2",
Some(1),
),
],
false,
);
let text = list.fmt_text_with_options(&FormatOptions::from_csv("url"));
assert!(text.contains("https://example.com/review/2"));
}
#[test]
fn format_review_comment_list_empty_still_shows_pr_context() {
let list = ReviewCommentList {
owner: "octo".into(),
repo: "hello-world".into(),
pr_number: 123,
pr_url: "https://github.com/octo/hello-world/pull/123".into(),
comments: vec![],
shown_threads: 0,
total_threads: 0,
has_more: false,
message: None,
};
let text = list.fmt_text_with_options(&FormatOptions::default());
assert!(text.contains("Review comments for octo/hello-world#123:"));
assert!(text.contains("No matching review comment threads."));
assert!(text.contains("complete — showing 0 of 0 threads"));
}
#[test]
fn format_pr_summary_list_basic() {
let list = sample_pr_summary_list(
vec![PrSummary {
number: 123,
title: "Fix bug".into(),
author: "dana".into(),
state: "open".into(),
created_at: "2025-01-01T00:00:00Z".into(),
updated_at: "2025-01-02T00:00:00Z".into(),
comment_count: 2,
review_comment_count: 3,
}],
false,
);
let text = list.fmt_text_with_options(&FormatOptions::default());
assert!(text.contains("Pull requests for octo/hello-world (state=open):"));
assert!(text.contains("PRs shown: 1 of 1"));
assert!(text.contains("#123 open — Fix bug"));
assert!(!text.contains("comments=")); assert!(!text.contains("(by ")); assert!(text.contains("complete — showing 1 of 1 pull requests"));
}
#[test]
fn format_pr_summary_list_shows_more_results_footer() {
let list = sample_pr_summary_list(
vec![PrSummary {
number: 123,
title: "Fix bug".into(),
author: "dana".into(),
state: "open".into(),
created_at: "2025-01-01T00:00:00Z".into(),
updated_at: "2025-01-02T00:00:00Z".into(),
comment_count: 2,
review_comment_count: 3,
}],
true,
);
let text = list.fmt_text_with_options(&FormatOptions::default());
assert!(text.contains("PRs shown: 1 of 2"));
assert!(text.contains("more results — showing 1 of 2 pull requests"));
assert!(text.contains("call gh_get_prs again with same params"));
}
#[test]
fn format_pr_summary_list_empty_still_shows_context() {
let list = PrSummaryList {
owner: "octo".into(),
repo: "hello-world".into(),
state: "open".into(),
prs: vec![],
shown_prs: 0,
total_prs: 0,
has_more: false,
message: None,
};
let text = list.fmt_text_with_options(&FormatOptions::default());
assert!(text.contains("Pull requests for octo/hello-world (state=open):"));
assert!(text.contains("No matching pull requests."));
assert!(text.contains("complete — showing 0 of 0 pull requests"));
}
#[test]
fn extras_flags_parsing() {
let opts = FormatOptions::from_csv("id,url,dates,review,counts,author");
assert!(opts.show_ids);
assert!(opts.show_urls);
assert!(opts.show_dates);
assert!(opts.show_review_ids);
assert!(opts.show_counts);
assert!(opts.show_author);
let default_opts = FormatOptions::from_csv("");
assert!(default_opts.show_ids);
assert!(!default_opts.show_urls);
assert!(!default_opts.show_dates);
assert!(!default_opts.show_review_ids);
assert!(!default_opts.show_counts);
assert!(!default_opts.show_author);
}
#[test]
fn wrapper_serializes_as_object() {
let w = ReviewCommentList {
owner: "octo".into(),
repo: "hello-world".into(),
pr_number: 123,
pr_url: "https://github.com/octo/hello-world/pull/123".into(),
comments: vec![],
shown_threads: 0,
total_threads: 0,
has_more: false,
message: None,
};
let s = serde_json::to_string(&w).unwrap();
assert!(s.contains("\"owner\""));
assert!(s.contains("\"repo\""));
assert!(s.contains("\"pr_number\""));
assert!(s.contains("\"pr_url\""));
assert!(s.contains("\"comments\""));
assert!(s.contains("\"shown_threads\""));
assert!(s.contains("\"total_threads\""));
assert!(s.contains("\"has_more\""));
assert!(!s.contains("\"message\""));
}
#[test]
fn pagination_message_only_when_has_more() {
let list_complete = ReviewCommentList {
owner: "octo".into(),
repo: "hello-world".into(),
pr_number: 123,
pr_url: "https://github.com/octo/hello-world/pull/123".into(),
comments: vec![],
shown_threads: 5,
total_threads: 5,
has_more: false,
message: None,
};
assert!(list_complete.message.is_none());
let msg = "Showing 5 out of 15 threads. Call gh_get_comments again for more.".to_string();
let list_partial = ReviewCommentList {
owner: "octo".into(),
repo: "hello-world".into(),
pr_number: 123,
pr_url: "https://github.com/octo/hello-world/pull/123".into(),
comments: vec![],
shown_threads: 5,
total_threads: 15,
has_more: true,
message: Some(msg.clone()),
};
assert!(list_partial.message.is_some());
let m = list_partial.message.unwrap();
assert!(m.contains("Showing 5 out of 15 threads"));
assert!(m.contains("Call gh_get_comments again"));
let list_with_msg = ReviewCommentList {
owner: "octo".into(),
repo: "hello-world".into(),
pr_number: 123,
pr_url: "https://github.com/octo/hello-world/pull/123".into(),
comments: vec![],
shown_threads: 5,
total_threads: 15,
has_more: true,
message: Some(msg),
};
let s = serde_json::to_string(&list_with_msg).unwrap();
assert!(s.contains("\"message\""));
}
}