Skip to content
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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ sets the default repository when passing only a pull request number.

The CLI provides two subcommands:

* `pr` — show unresolved pull request comments. A summary of files and comment
counts is printed first. When finished, `vk` prints an `end of code review`
banner. Pass file paths after the pull request to restrict output to those
paths.
* `issue` — read a GitHub issue (**to do**)
- `pr` — show unresolved pull request comments. It begins with a `code review`
banner, summarises files and comment counts, then prints an
`end of code review` banner. Pass file paths after the pull request to
restrict output to those paths.
Comment on lines +32 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

Standardise to en-GB-oxendict (-ize): change “summarises” to “summarizes”

Follow the house style (-ize). Update the wording accordingly.

-  banner, summarises files and comment counts, then prints an
+  banner, summarizes files and comment counts, then prints an
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- `pr` — show unresolved pull request comments. It begins with a `code review`
banner, summarises files and comment counts, then prints an
`end of code review` banner. Pass file paths after the pull request to
restrict output to those paths.
- `pr` — show unresolved pull request comments. It begins with a `code review`
banner, summarizes files and comment counts, then prints an
`end of code review` banner. Pass file paths after the pull request to
restrict output to those paths.
🧰 Tools
🪛 LanguageTool

[style] ~33-~33: Would you like to use the Oxford spelling “summarizes”? The spelling ‘summarises’ is also correct.
Context: ...t begins with a code review banner, summarises files and comment counts, then prints a...

(OXFORD_SPELLING_Z_NOT_S)

🤖 Prompt for AI Agents
In README.md around lines 32 to 35, change the word "summarises" to "summarizes"
to match the en-GB-oxendict (-ize) house style; update the sentence so it reads
"summarizes files and comment counts" while keeping the rest of the wording
unchanged.

- `issue` — read a GitHub issue (**to do**)

`vk` loads default values for subcommands from configuration files and
environment variables. When these defaults omit the required `reference` field,
Expand Down
16 changes: 9 additions & 7 deletions docs/vk-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ even when multiple comments reference the same code.
reducing clutter when multiple remarks target the same line.
- **Error visibility**: Failures encountered while printing a thread are logged
to stderr instead of being silently discarded.
- **Completion notice**: A final banner marks the *end of code review*.
- **Banners**: Output opens with a `code review` banner and ends with an
`end of code review` banner, framing the printed threads.

## Architecture

Expand All @@ -32,11 +33,12 @@ The code centres on three printing helpers:
2. `write_comment` includes the diff for the first comment in a thread.
3. `write_thread` iterates over a thread and prints each comment body in turn.

`run_pr` fetches the latest review banner from each reviewer and all unresolved
threads. The reviews are printed after the summary and before individual
threads. Errors from `print_thread` are surfaced via logging. Once all threads
have been printed, a final banner reading `end of code review` confirms
completion.
`run_pr` fetches the latest review from each reviewer and all unresolved
threads. After printing a `code review` banner and a summary, the reviews are
printed before individual threads. Broken pipe errors terminate output early;
other errors from `print_thread` and banner printing are surfaced via logging.
Once all threads have been printed, a final banner reading `end of code review`
confirms completion.

### CLI arguments

Expand Down Expand Up @@ -120,7 +122,7 @@ classDiagram
ReviewComment "0..*" --> "0..1" User : author
CommentConnection "1" --> "1" PageInfo : pageInfo

class ReviewThreadsService <<service>> {
class ReviewThreadsService {
+fetchReviewThreads(client: GraphQLClient, repo: String, number: Int): [ReviewThread!]!
}
```
Expand Down
132 changes: 100 additions & 32 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
//! `review_threads` for fetching review data, `issues` for issue retrieval,
//! `summary` for summarising comments, and `config` for configuration
//! management. When a thread has multiple comments on the same diff, the diff
//! is shown only once. After all comments are printed, the tool displays an
//! `end of code review` banner so calling processes know the output has
//! finished.
//! is shown only once. Output is framed by a `code review` banner at the start
//! and an `end of code review` banner at the end so calling processes can
//! reliably detect boundaries. The module re-exports banner helpers
//! [`print_start_banner`] and [`print_end_banner`] alongside summary utilities
//! [`print_summary`], [`summarize_files`], and [`write_summary`] so consumers can
//! reuse the framing and summarisation logic.

pub mod api;
mod boxed;
Expand All @@ -33,18 +36,20 @@ pub use review_threads::{
CommentConnection, PageInfo, ReviewComment, ReviewThread, User, fetch_review_threads,
filter_threads_by_files,
};
pub use summary::{print_end_banner, print_summary, summarize_files, write_summary};
pub use summary::{
print_end_banner, print_start_banner, print_summary, summarize_files, write_summary,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

use crate::cli_args::{GlobalArgs, IssueArgs, PrArgs};
use crate::printer::{print_reviews, write_thread};
use crate::ref_parser::{parse_issue_reference, parse_pr_reference};
use crate::reviews::{fetch_reviews, latest_reviews};
use crate::ref_parser::{RepoInfo, parse_issue_reference, parse_pr_reference};
use crate::reviews::{PullRequestReview, fetch_reviews, latest_reviews};
use clap::{Parser, Subcommand};
use log::{error, warn};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::env;
use std::io::ErrorKind;
use std::io::{ErrorKind, Write};
use std::sync::LazyLock;
use termimad::MadSkin;
use thiserror::Error;
Expand Down Expand Up @@ -163,7 +168,24 @@ fn is_broken_pipe_io(err: &std::io::Error) -> bool {
err.kind() == ErrorKind::BrokenPipe
}

async fn run_pr(args: PrArgs, global: &GlobalArgs) -> Result<(), VkError> {
fn handle_banner<F>(print: F, label: &str) -> bool
where
F: FnOnce() -> std::io::Result<()>,
{
if let Err(e) = print() {
if is_broken_pipe_io(&e) {
return true;
}
error!("error printing {label} banner: {e}");
}
false
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Prepare PR context, validate environment and print the start banner.
fn setup_pr_output(
args: &PrArgs,
global: &GlobalArgs,
) -> Result<(RepoInfo, u64, GraphQLClient), VkError> {
let reference = args.reference.as_deref().ok_or(VkError::InvalidRef)?;
let (repo, number) = parse_pr_reference(reference, global.repo.as_deref())?;
let token = env::var("GITHUB_TOKEN").unwrap_or_default();
Expand All @@ -173,30 +195,45 @@ async fn run_pr(args: PrArgs, global: &GlobalArgs) -> Result<(), VkError> {
if !locale_is_utf8() {
warn!("terminal locale is not UTF-8; emojis may not render correctly");
}

if handle_banner(print_start_banner, "start") {
return Err(VkError::Io(Box::new(std::io::Error::new(
ErrorKind::BrokenPipe,
"broken pipe",
))));
}
let client = build_graphql_client(&token, global.transcript.as_ref())?;
let threads = filter_threads_by_files(
fetch_review_threads(&client, &repo, number).await?,
&args.files,
);
// Avoid fetching reviews when there are no unresolved threads.
if threads.is_empty() {
if args.files.is_empty() {
println!("No unresolved comments.");
} else {
println!("No unresolved comments for the specified files.");
}
// Preserve the end-of-review banner for consumers that parse it.
if let Err(e) = print_end_banner() {
if is_broken_pipe_io(&e) {
return Ok(());
}
error!("error printing end banner: {e}");
Ok((repo, number, client))
}
Comment on lines +198 to +206
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

Avoid modelling BrokenPipe as an error for control flow in setup_pr_output

Return an explicit early-exit signal instead of synthesising a BrokenPipe Io error only to catch-and-ignore it in run_pr. This reduces cognitive load and keeps error paths for actual failures.

  • Option A: Change setup_pr_output to return Result<Option<(RepoInfo, u64, GraphQLClient)>, VkError>, where None means early-exit on BrokenPipe.
  • Option B: Introduce a small enum: enum PrSetup { Continue(RepoInfo, u64, GraphQLClient), EarlyExit }.

Follow-up if you want a ready patch.

🤖 Prompt for AI Agents
In src/main.rs around lines 198 to 206, setup_pr_output currently synthesizes an
IoError(ErrorKind::BrokenPipe) to signal an early exit; change its API to return
an explicit control-flow signal instead of a fake error. Replace the current
Result<(RepoInfo, u64, GraphQLClient), VkError> with either
Result<Option<(RepoInfo, u64, GraphQLClient)>, VkError> (Option None =
early-exit) or a small enum like enum PrSetup { Continue(RepoInfo, u64,
GraphQLClient), EarlyExit } and return EarlyExit/None where you currently
construct the BrokenPipe error; then update callers (notably run_pr) to match
the new return shape: handle the EarlyExit/None as a normal early-return path
(no error logging) and only treat Err(VkError) as real failures. Ensure you
remove the synthesized BrokenPipe creation and corresponding catch-and-ignore
logic.


/// Print an appropriate message when no threads match and append the end banner.
#[allow(
clippy::unnecessary_wraps,
reason = "returns Result for interface symmetry"
)]
fn handle_empty_threads(files: &[String]) -> Result<(), VkError> {
Comment on lines +209 to +213
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Replace #[allow(...)] with narrowly scoped #[expect(..., reason = "...")]

House style forbids #[allow]; use #[expect] with a reason instead. This silences the lint in a documented, tightly scoped way.

-#[allow(
-    clippy::unnecessary_wraps,
-    reason = "returns Result for interface symmetry"
-)]
+#[expect(clippy::unnecessary_wraps, reason = "returns Result for interface symmetry")]
 fn handle_empty_threads(files: &[String]) -> Result<(), VkError> {
     …
 }
 
-#[allow(clippy::unnecessary_wraps, reason = "future error cases may emerge")]
+#[expect(clippy::unnecessary_wraps, reason = "future error cases may emerge")]
 fn generate_pr_output(
     threads: Vec<ReviewThread>,
     reviews: Vec<PullRequestReview>,
 ) -> Result<(), VkError> {
     …
 }

Also applies to: 231-233

🤖 Prompt for AI Agents
In src/main.rs around lines 209-213 (and similarly at 231-233), replace the
module-level #[allow(...)] attribute with a narrowly scoped
#[expect(clippy::unnecessary_wraps, reason = "...")] applied immediately above
the specific function(s); specifically, remove the existing #[allow(..., reason
= "...")] and add #[expect(clippy::unnecessary_wraps, reason = "returns Result
for interface symmetry")] directly on the fn handle_empty_threads signature (and
the other function at 231-233), so the lint is silenced with a documented
expectation rather than a broad allow.

let msg = if files.is_empty() {
"No unresolved comments."
} else {
"No unresolved comments for the specified files."
};
if let Err(e) = writeln!(std::io::stdout().lock(), "{msg}") {
if is_broken_pipe_io(&e) {
return Ok(());
}
error!("error writing empty state: {e}");
}
if handle_banner(print_end_banner, "end") {
return Ok(());
}
let reviews = fetch_reviews(&client, &repo, number).await?;
Ok(())
}

/// Render the summary, reviews and threads, then print the closing banner.
#[allow(clippy::unnecessary_wraps, reason = "future error cases may emerge")]
fn generate_pr_output(
threads: Vec<ReviewThread>,
reviews: Vec<PullRequestReview>,
) -> Result<(), VkError> {
let summary = summarize_files(&threads);
print_summary(&summary);

Expand All @@ -220,15 +257,33 @@ async fn run_pr(args: PrArgs, global: &GlobalArgs) -> Result<(), VkError> {
}
}

if let Err(e) = print_end_banner() {
if is_broken_pipe_io(&e) {
return Ok(());
}
error!("error printing end banner: {e}");
if handle_banner(print_end_banner, "end") {
return Ok(());
}
Ok(())
}

async fn run_pr(args: PrArgs, global: &GlobalArgs) -> Result<(), VkError> {
let (repo, number, client) = match setup_pr_output(&args, global) {
Ok(v) => v,
Err(VkError::Io(e)) if e.kind() == ErrorKind::BrokenPipe => return Ok(()),
Err(e) => return Err(e),
};

let threads = filter_threads_by_files(
fetch_review_threads(&client, &repo, number).await?,
&args.files,
);

if threads.is_empty() {
handle_empty_threads(&args.files)?;
return Ok(());
}

let reviews = fetch_reviews(&client, &repo, number).await?;
generate_pr_output(threads, reviews)
}

async fn run_issue(args: IssueArgs, global: &GlobalArgs) -> Result<(), VkError> {
let reference = args.reference.as_deref().ok_or(VkError::InvalidRef)?;
let (repo, number) = parse_issue_reference(reference, global.repo.as_deref())?;
Expand Down Expand Up @@ -443,4 +498,17 @@ mod tests {
assert!(out.contains("\u{25B6} hello"));
assert!(!out.contains("bye"));
}

#[test]
fn handle_banner_returns_true_on_broken_pipe() {
let broken_pipe =
|| -> std::io::Result<()> { Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe)) };
assert!(super::handle_banner(broken_pipe, "start"));
}

#[test]
fn handle_banner_logs_and_returns_false_on_other_errors() {
let other_err = || -> std::io::Result<()> { Err(std::io::Error::other("boom")) };
assert!(!super::handle_banner(other_err, "end"));
}
}
Loading
Loading