use diffler_core::session::{Anchor, Comment, CommentStatus, Reply};
use diffler_core::source::ReviewSource;
use super::App;
use crate::ci::{PrComment, ReviewVerdict};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrPost {
Review {
review: crate::ci::NewPrReview,
comment_ids: Vec<String>,
},
Reply {
number: u64,
comment_id: String,
reply_index: usize,
parent_remote_id: String,
body: String,
},
Resolve {
number: u64,
comment_id: String,
thread_id: String,
resolved: bool,
},
Edit {
number: u64,
comment_id: String,
remote_id: String,
body: String,
},
Delete {
number: u64,
comment_id: String,
remote_id: String,
},
}
#[derive(Debug, Default)]
pub(crate) struct PrPending {
pub review_comments: Vec<crate::ci::NewPrComment>,
pub comment_ids: Vec<String>,
pub replies: Vec<PrPost>,
pub agent_withheld: usize,
pub file_level: usize,
}
impl PrPending {
pub(crate) fn summary(&self) -> Vec<String> {
let mut lines = Vec::new();
let (comments, replies) = (self.review_comments.len(), self.replies.len());
lines.push(match (comments, replies) {
(0, 0) => "no comments pending".to_owned(),
(c, 0) => format!("posting {c} {}", plural(c, "comment", "comments")),
(0, r) => format!("posting {r} thread {}", plural(r, "reply", "replies")),
(c, r) => format!(
"posting {}: {c} {}, {r} thread {}",
comments + replies,
plural(c, "comment", "comments"),
plural(r, "reply", "replies")
),
});
if self.agent_withheld > 0 {
lines.push(format!(
"{} agent {} held back (the forge post is yours)",
self.agent_withheld,
plural(self.agent_withheld, "reply", "replies")
));
}
if self.file_level > 0 {
lines.push(format!(
"{} whole-file {} held back (a review carries line comments only)",
self.file_level,
plural(self.file_level, "comment", "comments")
));
}
lines
}
}
fn plural<'a>(count: usize, one: &'a str, many: &'a str) -> &'a str {
if count == 1 { one } else { many }
}
fn absorb_published_locals(roots: &mut [Comment], locals: &[Comment]) -> Vec<String> {
let mut absorbed = Vec::new();
for root in roots {
let Some(local) = locals.iter().find(|c| {
c.remote_id.is_none()
&& c.body == root.body
&& c.anchor.file == root.anchor.file
&& c.anchor.line == root.anchor.line
}) else {
continue;
};
root.replies.extend(
local
.replies
.iter()
.filter(|r| r.remote_id.is_none())
.cloned(),
);
absorbed.push(local.id.clone());
}
absorbed
}
impl App {
pub(crate) fn open_prs(&mut self) {
if self.ci_remotes().is_empty() {
self.info("no forge detected for this repo");
return;
}
self.prs_cursor = 0;
self.push_screen(super::Screen::Prs);
self.pending_ci = Some(super::CiRequest::Prs);
}
pub(crate) fn on_prs_event(&mut self, prs: Vec<crate::ci::PullRequest>) -> super::Flow {
let anchor = self.status_cursor_anchor();
self.prs = prs;
self.status.prs_loaded = true;
self.status.prs_in_flight = false;
self.prs_cursor = self.prs_cursor.min(self.prs.len().saturating_sub(1));
self.restore_status_cursor(anchor);
super::Flow::Continue
}
pub(crate) fn dispatch_prs(&mut self, action: crate::keymap::Action) {
use crate::keymap::Action;
let last = self.prs.len().saturating_sub(1);
match action {
Action::MoveDown => self.prs_cursor = (self.prs_cursor + 1).min(last),
Action::MoveUp => self.prs_cursor = self.prs_cursor.saturating_sub(1),
Action::GoTop => self.prs_cursor = 0,
Action::GoBottom => self.prs_cursor = last,
Action::HalfPageDown => {
self.prs_cursor = (self.prs_cursor + super::page_step(0, false)).min(last);
}
Action::FullPageDown => {
self.prs_cursor = (self.prs_cursor + super::page_step(0, true)).min(last);
}
Action::HalfPageUp => {
self.prs_cursor = self.prs_cursor.saturating_sub(super::page_step(0, false));
}
Action::FullPageUp => {
self.prs_cursor = self.prs_cursor.saturating_sub(super::page_step(0, true));
}
Action::Refresh => self.pending_ci = Some(super::CiRequest::Prs),
Action::Open => {
if let Some(pr) = self.prs.get(self.prs_cursor).cloned() {
self.open_pr_review_for(pr);
}
}
Action::BranchCheckout => self.checkout_selected_pr(),
Action::CopyUrl => self.copy_selected_pr_url(),
_ => {}
}
}
fn copy_selected_pr_url(&mut self) {
let Some(pr) = self.prs.get(self.prs_cursor) else {
self.info("no pull request under the cursor");
return;
};
let (number, url) = (pr.number, pr.url.clone());
self.copy_or_report(url, &format!("#{number}"));
}
pub(crate) const PR_FETCH_PREFIX: &'static str = "fetch PR #";
pub(crate) fn pr_fetch_label(number: u64) -> String {
format!("{}{number}", Self::PR_FETCH_PREFIX)
}
pub(crate) fn checkout_selected_pr(&mut self) {
let Some(pr) = self.prs.get(self.prs_cursor).cloned() else {
return;
};
let github = self
.ci_remotes()
.first()
.is_some_and(|r| matches!(r.detected.kind, crate::ci::ProviderKind::GitHub));
if github {
self.pending_git = Some(super::GitOp {
label: format!("checkout PR #{}", pr.number),
argv: vec![
"gh".to_owned(),
"pr".to_owned(),
"checkout".to_owned(),
pr.number.to_string(),
],
});
return;
}
let remote = self
.ci_remotes()
.first()
.map_or_else(|| "origin".to_owned(), |r| r.name.clone());
self.pending_pr_switch = Some(pr.head_ref.clone());
self.pending_git = Some(super::GitOp {
label: Self::pr_fetch_label(pr.number),
argv: vec![
"git".to_owned(),
"fetch".to_owned(),
remote,
format!("refs/pull/{}/head:refs/heads/{}", pr.number, pr.head_ref),
],
});
}
pub(crate) fn on_pr_comments_event(
&mut self,
number: u64,
remote: &[PrComment],
pr: Option<crate::ci::PullRequest>,
) -> super::Flow {
if let Some(pr) = pr {
self.on_pr_head_seen(&pr);
}
self.sync_pr_comments(number, remote);
super::Flow::Continue
}
pub(crate) fn on_pr_posted_event(
&mut self,
post: &PrPost,
result: Result<Option<PrComment>, String>,
) -> super::Flow {
self.on_pr_posted(post, result);
super::Flow::Continue
}
pub(crate) fn sync_pr_comments(&mut self, number: u64, remote: &[PrComment]) {
let source = ReviewSource::pr(number);
let model = self.source_model(&source);
let mut roots: Vec<Comment> = Vec::new();
for item in remote.iter().filter(|c| c.reply_to.is_none()) {
let line_text = item.line.and_then(|line| {
model
.find_line(&item.path, line, !item.new_side)
.map(|l| l.text.clone())
});
let status = if item.resolved {
CommentStatus::Resolved
} else {
CommentStatus::Open
};
roots.push(Comment {
id: format!("remote-{}", item.id),
remote_id: Some(item.id.clone()),
thread_id: item.thread_id.clone(),
author: item.author.clone(),
anchor: Anchor {
file: item.path.clone(),
line: item.start_line.or(item.line),
line_end: item.start_line.and(item.line),
on_old_side: !item.new_side,
line_text,
},
body: item.body.clone(),
status,
replies: Vec::new(),
at: item.at,
});
}
let inflight = self.pr_posts_inflight.clone();
let forge_resolves = self.forge_resolves_threads();
for item in remote.iter().filter(|c| c.reply_to.is_some()) {
if let Some(root) = roots.iter_mut().find(|c| c.remote_id == item.reply_to) {
root.replies.push(Reply {
remote_id: Some(item.id.clone()),
author: item.author.clone(),
body: item.body.clone(),
at: item.at,
});
}
}
let session = self.review.session_for_mut(&source);
for root in &mut roots {
let Some(prior) = session
.comments
.iter()
.find(|c| c.remote_id == root.remote_id)
else {
continue;
};
let flip_inflight = inflight
.iter()
.any(|key| key.starts_with(&resolve_key_prefix(&root.id)));
root.status = if flip_inflight {
prior.status
} else {
match (root.status, prior.status) {
(CommentStatus::Resolved, _) => CommentStatus::Resolved,
(_, CommentStatus::Resolved) if forge_resolves => CommentStatus::Open,
(_, prior) => prior,
}
};
let edit_inflight = inflight
.iter()
.any(|key| key.starts_with(&edit_key_prefix(&root.id)));
if edit_inflight {
root.body.clone_from(&prior.body);
}
root.replies.extend(
prior
.replies
.iter()
.filter(|r| r.remote_id.is_none())
.cloned(),
);
}
let absorbed = absorb_published_locals(&mut roots, &session.comments);
let mut merged: Vec<Comment> = session
.comments
.iter()
.filter(|c| c.remote_id.is_none() && !absorbed.contains(&c.id))
.cloned()
.collect();
merged.extend(roots);
merged.sort_by_key(|c| c.at);
if merged == session.comments {
return;
}
session.comments = merged;
if let Err(err) = self.review.save_for(&source) {
self.error(err.to_string());
}
if let Some(diff) = self.diff.as_mut() {
diff.invalidate();
}
}
pub(crate) fn submit_pr_review(&mut self) {
let ReviewSource::Pr { number } = self.active_review_source() else {
self.info("not reviewing a PR: nothing to submit");
return;
};
let Some(pending) = self.pr_pending(number) else {
return;
};
self.modal = Some(super::Modal::ReviewVerdict {
number,
summary: pending.summary(),
});
}
pub(crate) fn pr_review_verdict_chosen(&mut self, number: u64, verdict: ReviewVerdict) {
self.open_input(
"Review summary (optional)".to_owned(),
String::new(),
super::InputOp::ReviewBody { number, verdict },
);
}
fn counterpart_line(&self, anchor: &Anchor, line: u32) -> Option<u32> {
let model = self
.diff
.as_ref()
.and_then(|diff| diff.commit_model.as_ref())
.unwrap_or_else(|| self.review.model());
let row = model.find_line(&anchor.file, line, anchor.on_old_side)?;
if anchor.on_old_side {
row.new_no
} else {
row.old_no
}
}
pub(crate) fn pr_pending(&self, number: u64) -> Option<PrPending> {
let (_, head) = self.pr_ranges.get(&number).cloned()?;
let session = self.review.session_for(&ReviewSource::pr(number));
let mut pending = PrPending::default();
for comment in &session.comments {
match (&comment.remote_id, comment.anchor.line) {
(None, Some(line)) => {
let (start_line, line) = match comment.anchor.line_end {
Some(end) if end != line => (Some(line), end),
_ => (None, line),
};
let bodies = std::iter::once((&comment.author, &comment.body)).chain(
comment
.replies
.iter()
.map(|reply| (&reply.author, &reply.body)),
);
let (mut posted, mut withheld) = (false, false);
for (author, body) in bodies {
if author == crate::mcp::AGENT_AUTHOR {
pending.agent_withheld += 1;
withheld = true;
continue;
}
posted = true;
pending.review_comments.push(crate::ci::NewPrComment {
number,
head_oid: head.clone(),
path: comment.anchor.file.clone(),
line,
start_line,
new_side: !comment.anchor.on_old_side,
counterpart: self.counterpart_line(&comment.anchor, line),
body: body.clone(),
});
}
if posted && !withheld {
pending.comment_ids.push(comment.id.clone());
}
}
(Some(parent), _) => {
for (reply_index, reply) in comment.replies.iter().enumerate() {
if reply.remote_id.is_none() {
if reply.author == crate::mcp::AGENT_AUTHOR {
pending.agent_withheld += 1;
continue;
}
pending.replies.push(PrPost::Reply {
number,
comment_id: comment.id.clone(),
reply_index,
parent_remote_id: parent.clone(),
body: reply.body.clone(),
});
}
}
}
_ => {}
}
}
pending.file_level = session
.comments
.iter()
.filter(|c| c.remote_id.is_none() && c.anchor.line.is_none())
.count();
Some(pending)
}
pub(crate) fn queue_pr_review(&mut self, number: u64, verdict: ReviewVerdict, body: &str) {
let Some((_, head)) = self.pr_ranges.get(&number).cloned() else {
return;
};
let Some(PrPending {
review_comments,
comment_ids,
replies,
file_level,
..
}) = self.pr_pending(number)
else {
return;
};
if verdict == ReviewVerdict::RequestChanges && body.is_empty() {
self.info("request changes needs a summary");
return;
}
if verdict == ReviewVerdict::Comment
&& review_comments.is_empty()
&& body.is_empty()
&& replies.is_empty()
{
self.info("nothing pending to submit");
return;
}
let total = review_comments.len() + replies.len();
let mut posts = replies;
if !review_comments.is_empty() || verdict != ReviewVerdict::Comment || !body.is_empty() {
posts.push(PrPost::Review {
review: crate::ci::NewPrReview {
number,
head_oid: head,
verdict,
body: body.to_owned(),
comments: review_comments,
},
comment_ids,
});
}
for post in posts {
self.queue_pr_post(post);
}
let label = match verdict {
ReviewVerdict::Approve => "approving",
ReviewVerdict::RequestChanges => "requesting changes",
ReviewVerdict::Comment => "commenting",
};
let mut message = format!(
"submitting review: {label} ({total} {})…",
plural(total, "comment", "comments")
);
if file_level > 0 {
use std::fmt::Write as _;
let _ = write!(
message,
" {file_level} whole-file {} held back",
plural(file_level, "comment", "comments")
);
}
self.info(message);
}
fn queue_pr_post(&mut self, post: PrPost) {
if self.pr_posts_inflight.insert(post_key(&post)) {
self.pending_pr_posts.push(post);
}
}
pub fn drop_pending_pr_posts(&mut self) {
for post in self.pending_pr_posts.drain(..) {
self.pr_posts_inflight.remove(&post_key(&post));
}
}
pub(crate) fn on_pr_posted(
&mut self,
post: &PrPost,
result: Result<Option<PrComment>, String>,
) {
self.pr_posts_inflight.remove(&post_key(post));
let number = match post {
PrPost::Review { review, .. } => review.number,
PrPost::Reply { number, .. }
| PrPost::Resolve { number, .. }
| PrPost::Edit { number, .. }
| PrPost::Delete { number, .. } => *number,
};
let source = ReviewSource::pr(number);
match result {
Ok(remote) => {
let session = self.review.session_for_mut(&source);
match post {
PrPost::Review { comment_ids, .. } => {
session.comments.retain(|c| !comment_ids.contains(&c.id));
self.pending_ci = Some(super::CiRequest::PrComments(number));
}
PrPost::Reply {
comment_id, body, ..
} => {
if let Some(r) = session
.comments
.iter_mut()
.find(|c| c.id == *comment_id)
.and_then(|c| {
c.replies
.iter_mut()
.find(|r| r.remote_id.is_none() && r.body == *body)
})
{
r.remote_id = remote.map(|c| c.id);
}
}
PrPost::Resolve { .. } | PrPost::Edit { .. } => {}
PrPost::Delete { comment_id, .. } => {
session.comments.retain(|c| c.id != *comment_id);
self.pending_ci = Some(super::CiRequest::PrComments(number));
}
}
if let Err(err) = self.review.save_for(&source) {
self.error(err.to_string());
}
if let Some(diff) = self.diff.as_mut() {
diff.invalidate();
}
}
Err(err) => {
if let PrPost::Resolve {
comment_id,
resolved,
..
} = post
{
let session = self.review.session_for_mut(&source);
if let Some(comment) = session.comments.iter_mut().find(|c| c.id == *comment_id)
{
comment.status = if *resolved {
CommentStatus::Open
} else {
CommentStatus::Resolved
};
}
if let Some(diff) = self.diff.as_mut() {
diff.invalidate();
}
}
self.error(format!("posting to the PR failed: {err}"));
}
}
}
pub(crate) fn on_pr_head_seen(&mut self, pr: &crate::ci::PullRequest) {
let moved = self
.pr_ranges
.get(&pr.number)
.is_some_and(|(_, head)| *head != pr.head_oid);
if !moved || self.pending_pr_open.is_some() {
return;
}
self.info(format!("PR #{} head moved: refreshing the diff", pr.number));
self.open_pr_review_for(pr.clone());
}
pub(crate) fn queue_pr_comment_edit(
&mut self,
source: &ReviewSource,
comment_id: &str,
body: &str,
) {
let ReviewSource::Pr { number } = source else {
return;
};
let remote = self
.review
.session_for(source)
.comments
.iter()
.find(|c| c.id == comment_id)
.and_then(|c| c.remote_id.clone());
let Some(remote_id) = remote else {
return;
};
let post = PrPost::Edit {
number: *number,
comment_id: comment_id.to_owned(),
remote_id,
body: body.to_owned(),
};
self.queue_pr_post(post);
}
pub(crate) fn queue_pr_comment_delete(
&mut self,
number: u64,
comment_id: &str,
remote_id: &str,
) {
let post = PrPost::Delete {
number,
comment_id: comment_id.to_owned(),
remote_id: remote_id.to_owned(),
};
self.queue_pr_post(post);
}
pub(crate) fn queue_pr_resolve(
&mut self,
number: u64,
comment_id: &str,
resolved: bool,
) -> bool {
if !self.forge_resolves_threads() {
return true;
}
let thread = self
.review
.session_for(&ReviewSource::pr(number))
.comments
.iter()
.find(|c| c.id == comment_id)
.and_then(|c| c.thread_id.clone());
let Some(thread_id) = thread else {
self.info("no forge thread for this comment yet: sync pending");
return false;
};
let post = PrPost::Resolve {
number,
comment_id: comment_id.to_owned(),
thread_id,
resolved,
};
self.queue_pr_post(post);
true
}
fn forge_resolves_threads(&self) -> bool {
self.ci_remotes()
.first()
.is_none_or(|remote| crate::ci::capabilities_for(remote.detected.kind).resolve_threads)
}
}
fn post_key(post: &PrPost) -> String {
match post {
PrPost::Review { review, .. } => format!("review-{}", review.number),
PrPost::Reply {
comment_id,
reply_index,
..
} => format!("r-{comment_id}-{reply_index}"),
PrPost::Resolve {
comment_id,
resolved,
..
} => format!("res-{comment_id}-{resolved}"),
PrPost::Edit {
comment_id, body, ..
} => {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut hasher = DefaultHasher::new();
body.hash(&mut hasher);
format!("e-{comment_id}-{:x}", hasher.finish())
}
PrPost::Delete { comment_id, .. } => format!("d-{comment_id}"),
}
}
fn resolve_key_prefix(comment_id: &str) -> String {
format!("res-{comment_id}-")
}
fn edit_key_prefix(comment_id: &str) -> String {
format!("e-{comment_id}-")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::LoadedConfig;
use crate::test_support::standard_fixture;
fn listed_pr(url: Option<&str>) -> crate::ci::PullRequest {
crate::ci::PullRequest {
number: 9,
title: "remote only".into(),
url: url.map(str::to_owned),
base_ref: "main".into(),
head_ref: "feat/remote".into(),
head_oid: "0000000000000000000000000000000000000abc".into(),
author: "reviewer".into(),
}
}
#[test]
fn y_copies_the_forge_url_of_the_selected_pull_request() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.prs = vec![listed_pr(Some(
"https://example.invalid/acme/widgets/pull/9",
))];
app.dispatch_prs(crate::keymap::Action::CopyUrl);
assert_eq!(
app.pending_clipboard.as_deref(),
Some("https://example.invalid/acme/widgets/pull/9")
);
}
#[test]
fn copying_a_pull_request_with_no_url_says_so_and_copies_nothing() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.prs = vec![listed_pr(None)];
app.dispatch_prs(crate::keymap::Action::CopyUrl);
assert!(app.pending_clipboard.is_none());
assert!(
app.message
.as_ref()
.is_some_and(|m| m.text.contains("no URL for #9")),
"{:?}",
app.message
);
}
#[test]
fn copying_with_an_empty_pull_request_list_copies_nothing() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.dispatch_prs(crate::keymap::Action::CopyUrl);
assert!(app.pending_clipboard.is_none());
}
#[test]
fn reviewing_a_listed_pr_never_needs_a_checkout() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.prs = vec![crate::ci::PullRequest {
number: 9,
title: "remote only".into(),
url: None,
base_ref: "main".into(),
head_ref: "feat/remote".into(),
head_oid: "0000000000000000000000000000000000000abc".into(),
author: "alice".into(),
}];
app.dispatch_prs(crate::keymap::Action::Open);
let git = app.pending_git.take().expect("fetch queued");
assert!(
git.argv.iter().any(|a| a == "refs/pull/9/head"),
"{:?}",
git.argv
);
assert_eq!(app.pending_pr_open.as_ref().map(|p| p.number), Some(9));
}
#[test]
fn the_submit_summary_counts_what_gets_posted_including_agent_replies() {
let fixture = standard_fixture();
fixture.write("src/lib.rs", "pub fn answer() -> u32 {\n 43\n}\n");
fixture.stage("src/lib.rs");
fixture.commit_all("bump");
let mut app = App::new(fixture.review(), LoadedConfig::default());
let head = app.review.vcs.resolve("HEAD").expect("head oid");
let base = app.review.vcs.resolve("HEAD~1").expect("base oid");
app.open_pr_diff(3, &base, &head);
app.sync_pr_comments(
3,
&[PrComment {
id: "55".into(),
path: "src/lib.rs".into(),
line: Some(1),
new_side: true,
body: "remote".into(),
author: "alice".into(),
reply_to: None,
start_line: None,
thread_id: None,
resolved: false,
at: 1,
}],
);
let source = ReviewSource::pr(3);
let session = app.review.session_for_mut(&source);
let remote_id = session.comments[0].id.clone();
session.reply(&remote_id, crate::mcp::AGENT_AUTHOR, "agent on the thread");
let local = session
.add_comment(
Anchor {
file: "src/lib.rs".into(),
line: Some(2),
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"human comment",
)
.id
.clone();
session.reply(&local, crate::mcp::AGENT_AUTHOR, "agent under the comment");
session.add_comment(
Anchor {
file: "src/lib.rs".into(),
line: None,
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"file level",
);
let pending = app.pr_pending(3).expect("plan");
assert_eq!(pending.review_comments.len(), 1);
assert_eq!(pending.review_comments[0].body, "human comment");
assert!(pending.replies.is_empty(), "the agent's thread reply stays");
assert_eq!(pending.agent_withheld, 2);
assert_eq!(pending.file_level, 1);
assert_eq!(
pending.summary(),
vec![
"posting 1 comment".to_owned(),
"2 agent replies held back (the forge post is yours)".to_owned(),
"1 whole-file comment held back (a review carries line comments only)".to_owned(),
]
);
app.queue_pr_review(3, ReviewVerdict::Comment, "");
let posted: Vec<String> = app
.pending_pr_posts
.iter()
.flat_map(|post| match post {
PrPost::Review { review, .. } => {
review.comments.iter().map(|c| c.body.clone()).collect()
}
PrPost::Reply { body, .. } => vec![body.clone()],
_ => Vec::new(),
})
.collect();
assert_eq!(
posted,
vec!["human comment".to_owned()],
"nothing the agent wrote reaches the forge"
);
}
#[test]
fn a_withheld_agent_reply_survives_publishing_its_parent() {
let fixture = standard_fixture();
fixture.write("src/lib.rs", "pub fn answer() -> u32 {\n 43\n}\n");
fixture.stage("src/lib.rs");
fixture.commit_all("bump");
let mut app = App::new(fixture.review(), LoadedConfig::default());
let head = app.review.vcs.resolve("HEAD").expect("head oid");
let base = app.review.vcs.resolve("HEAD~1").expect("base oid");
app.open_pr_diff(3, &base, &head);
let source = ReviewSource::pr(3);
let session = app.review.session_for_mut(&source);
let local = session
.add_comment(
Anchor {
file: "src/lib.rs".into(),
line: Some(2),
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"why 43?",
)
.id
.clone();
session.reply(&local, crate::mcp::AGENT_AUTHOR, "because tests say so");
app.queue_pr_review(3, ReviewVerdict::Comment, "");
let post = app.pending_pr_posts.remove(0);
app.handle(crate::event::AppEvent::PrPosted {
post: Box::new(post),
result: Ok(None),
});
assert!(
!app.review.session_for(&source).comments.is_empty(),
"the ack must not delete a comment still holding withheld text"
);
app.handle(crate::event::AppEvent::PrComments {
number: 3,
comments: vec![PrComment {
id: "900".into(),
path: "src/lib.rs".into(),
line: Some(2),
start_line: None,
new_side: true,
body: "why 43?".into(),
author: "reviewer".into(),
reply_to: None,
thread_id: Some("T_a".into()),
resolved: false,
at: 5,
}],
pr: Some(crate::ci::PullRequest {
number: 3,
title: "t".into(),
url: None,
base_ref: "main".into(),
head_ref: "feat".into(),
head_oid: head.clone(),
author: "reviewer".into(),
}),
});
let comments = &app.review.session_for(&source).comments;
assert_eq!(comments.len(), 1, "no duplicate of the published comment");
assert_eq!(
comments[0]
.replies
.iter()
.map(|r| &r.body)
.collect::<Vec<_>>(),
vec!["because tests say so"],
"the agent's reply rode across the handover"
);
}
#[test]
fn commenting_in_place_queues_a_forge_post() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let fixture = standard_fixture();
fixture.write("src/lib.rs", "pub fn answer() -> u32 {\n 43\n}\n");
fixture.stage("src/lib.rs");
fixture.commit_all("bump");
let mut app = App::new(fixture.review(), LoadedConfig::default());
let head = app.review.vcs.resolve("HEAD").expect("head oid");
let base = app.review.vcs.resolve("HEAD~1").expect("base oid");
app.open_pr_diff(3, &base, &head);
assert!(app.diff.is_some());
app.sync_pr_comments(
3,
&[PrComment {
id: "55".into(),
path: "src/lib.rs".into(),
line: Some(1),
new_side: true,
body: "remote".into(),
author: "alice".into(),
reply_to: None,
start_line: None,
thread_id: None,
resolved: false,
at: 1,
}],
);
let press = |app: &mut App, code: KeyCode| {
app.handle(crate::event::AppEvent::Key(KeyEvent::new(
code,
KeyModifiers::NONE,
)));
};
press(&mut app, KeyCode::Char('l'));
press(&mut app, KeyCode::Char('j'));
press(&mut app, KeyCode::Char('c'));
assert!(app.composer_open(), "the composer opens in place");
for ch in "ship it".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Enter);
assert!(
app.pending_pr_posts.is_empty(),
"comments stack locally until the review is submitted"
);
press(&mut app, KeyCode::Char('S'));
assert!(
matches!(
app.modal,
Some(crate::app::Modal::ReviewVerdict { number: 3, .. })
),
"S opens the verdict picker: {:?}",
app.modal
);
press(&mut app, KeyCode::Char('a'));
assert!(
matches!(app.modal, Some(crate::app::Modal::Input { .. })),
"verdict leads to the summary prompt"
);
for ch in "lgtm".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Enter);
assert_eq!(app.pending_pr_posts.len(), 1);
let PrPost::Review { review, .. } = &app.pending_pr_posts[0] else {
panic!("expected a review post: {:?}", app.pending_pr_posts);
};
assert_eq!(review.verdict, ReviewVerdict::Approve);
assert_eq!(review.body, "lgtm");
assert_eq!(review.comments.len(), 1);
assert_eq!(review.comments[0].body, "ship it");
}
#[test]
fn an_approval_needs_no_pending_comments_and_an_empty_summary_submits() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
let head = app.review.vcs.resolve("HEAD").expect("head oid");
app.pr_ranges.insert(4, (head.clone(), head));
let press = |app: &mut App, code: KeyCode| {
app.handle(crate::event::AppEvent::Key(KeyEvent::new(
code,
KeyModifiers::NONE,
)));
};
app.pr_review_verdict_chosen(4, ReviewVerdict::Approve);
assert!(
matches!(app.modal, Some(crate::app::Modal::Input { .. })),
"summary prompt opens"
);
press(&mut app, KeyCode::Enter);
let PrPost::Review { review, .. } = &app.pending_pr_posts[0] else {
panic!("expected a review post: {:?}", app.pending_pr_posts);
};
assert_eq!(review.verdict, ReviewVerdict::Approve);
assert!(review.body.is_empty());
assert!(review.comments.is_empty());
}
#[test]
fn request_changes_with_nothing_to_say_is_rejected() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
let head = app.review.vcs.resolve("HEAD").expect("head oid");
app.pr_ranges.insert(4, (head.clone(), head));
app.queue_pr_review(4, ReviewVerdict::RequestChanges, "");
assert!(app.pending_pr_posts.is_empty());
let message = app.message.clone().expect("message");
assert!(message.text.contains("needs a summary"), "{message:?}");
}
#[test]
fn next_comment_then_reply_opens_the_composer_in_a_pr_view() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let fixture = crate::test_support::Fixture::new();
fixture.write("app.py", "def greet(name):\n return f\"hello {name}\"\n");
fixture.stage("app.py");
fixture.commit_all("initial");
fixture.write("app.py", "def greet(name):\n return f\"hi {name}!\"\n");
fixture.stage("app.py");
fixture.commit_all("tweak");
let mut app = App::new(fixture.review(), LoadedConfig::default());
let head = app.review.vcs.resolve("HEAD").expect("head");
let base = app.review.vcs.resolve("HEAD~1").expect("base");
app.open_pr_diff(9, &base, &head);
app.sync_pr_comments(
9,
&[PrComment {
id: "70".into(),
path: "app.py".into(),
line: Some(2),
new_side: true,
body: "hm?".into(),
author: "alice".into(),
reply_to: None,
start_line: None,
thread_id: None,
resolved: false,
at: 1,
}],
);
if let Some(diff) = app.diff.as_mut() {
diff.ensure_rows(&app.review);
}
let press = |app: &mut App, code: KeyCode| {
app.handle(crate::event::AppEvent::Key(KeyEvent::new(
code,
KeyModifiers::NONE,
)));
};
press(&mut app, KeyCode::Char('}'));
press(&mut app, KeyCode::Char('r'));
let kinds: Vec<String> = app
.diff
.as_ref()
.map(|d| {
d.rows()
.iter()
.map(|r| format!("{r:?}").chars().take(30).collect())
.collect()
})
.unwrap_or_default();
assert!(
app.composer_open(),
"reply composer after }}+r; cursor={:?} rows={kinds:?}",
app.diff.as_ref().map(|d| d.cursor)
);
}
#[test]
fn a_moved_pr_head_queues_a_refetch_and_a_matching_head_does_not() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
let head = app.review.vcs.resolve("HEAD").expect("head oid");
app.pr_ranges.insert(5, ("base".into(), head.clone()));
let pr = crate::ci::PullRequest {
number: 5,
title: "t".into(),
url: None,
base_ref: "main".into(),
head_ref: "feat/x".into(),
head_oid: head.clone(),
author: "alice".into(),
};
app.on_pr_head_seen(&pr);
assert!(app.pending_git.is_none(), "same head: nothing to do");
let moved = crate::ci::PullRequest {
head_oid: "1111111111111111111111111111111111111111".into(),
..pr
};
app.on_pr_head_seen(&moved);
let git = app.pending_git.take().expect("refetch queued");
assert!(
git.argv.iter().any(|a| a == "refs/pull/5/head"),
"{:?}",
git.argv
);
assert_eq!(app.pending_pr_open.as_ref().map(|p| p.number), Some(5));
app.on_pr_head_seen(&moved);
assert!(app.pending_git.is_none(), "fetch already in flight");
}
#[test]
fn submit_ack_and_resync_round_trip() {
let fixture = standard_fixture();
fixture.write("src/lib.rs", "pub fn answer() -> u32 {\n 43\n}\n");
fixture.stage("src/lib.rs");
fixture.commit_all("bump");
let mut app = App::new(fixture.review(), LoadedConfig::default());
let head = app.review.vcs.resolve("HEAD").expect("head oid");
let base = app.review.vcs.resolve("HEAD~1").expect("base oid");
app.open_pr_diff(3, &base, &head);
let source = ReviewSource::pr(3);
let single = Anchor {
file: "src/lib.rs".into(),
line: Some(2),
line_end: None,
on_old_side: false,
line_text: None,
};
let range = Anchor {
file: "src/lib.rs".into(),
line: Some(1),
line_end: Some(3),
on_old_side: false,
line_text: None,
};
{
let session = app.review.session_for_mut(&source);
session.add_comment(single, "me", "lab single comment");
session.add_comment(range, "me", "lab range comment");
}
app.queue_pr_review(3, ReviewVerdict::Comment, "lab review body");
assert_eq!(app.pending_pr_posts.len(), 1);
let post = app.pending_pr_posts.remove(0);
let flow = app.handle(crate::event::AppEvent::PrPosted {
post: Box::new(post),
result: Ok(None),
});
assert!(matches!(flow, crate::app::Flow::Continue));
assert!(
app.review.session_for(&source).comments.is_empty(),
"locals handed to the forge"
);
let listing = vec![
PrComment {
id: "900".into(),
path: "src/lib.rs".into(),
line: Some(2),
start_line: None,
new_side: true,
body: "lab single comment".into(),
author: "me".into(),
reply_to: None,
thread_id: Some("T_a".into()),
resolved: false,
at: 5,
},
PrComment {
id: "901".into(),
path: "src/lib.rs".into(),
line: Some(3),
start_line: Some(1),
new_side: true,
body: "lab range comment".into(),
author: "me".into(),
reply_to: None,
thread_id: Some("T_b".into()),
resolved: false,
at: 6,
},
];
let pr = crate::ci::PullRequest {
number: 3,
title: "t".into(),
url: None,
base_ref: "main".into(),
head_ref: "feat".into(),
head_oid: head.clone(),
author: "me".into(),
};
app.handle(crate::event::AppEvent::PrComments {
number: 3,
comments: listing,
pr: Some(pr),
});
let session = app.review.session_for(&source);
assert_eq!(session.comments.len(), 2, "forge copies imported");
assert!(session.comments.iter().all(|c| c.remote_id.is_some()));
let ranged = session
.comments
.iter()
.find(|c| c.body == "lab range comment")
.expect("range comment");
assert_eq!(ranged.anchor.line, Some(1));
assert_eq!(ranged.anchor.line_end, Some(3));
}
#[test]
fn a_forge_without_resolution_keeps_the_local_resolve() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.ci_remotes = vec![super::super::CiRemote {
name: "origin".into(),
detected: crate::ci::Detected {
kind: crate::ci::ProviderKind::Forgejo,
host: Some("forge.example.com".into()),
},
url: None,
}];
let listing = [PrComment {
id: "100".into(),
path: "app.txt".into(),
line: Some(2),
new_side: true,
body: "remote root".into(),
author: "alice".into(),
reply_to: None,
start_line: None,
thread_id: Some("500".into()),
resolved: false,
at: 10,
}];
app.sync_pr_comments(7, &listing);
let source = ReviewSource::pr(7);
let id = app.review.session_for(&source).comments[0].id.clone();
assert!(app.queue_pr_resolve(7, &id, true));
assert!(app.pending_pr_posts.is_empty());
app.review.session_for_mut(&source).resolve(&id);
app.sync_pr_comments(7, &listing);
assert_eq!(
app.review.session_for(&source).comments[0].status,
CommentStatus::Resolved
);
}
#[test]
fn sync_keeps_local_replies_and_status_on_forge_roots() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
let listing = [PrComment {
id: "100".into(),
path: "app.txt".into(),
line: Some(2),
new_side: true,
body: "remote root".into(),
author: "alice".into(),
reply_to: None,
start_line: None,
thread_id: None,
resolved: false,
at: 10,
}];
app.sync_pr_comments(7, &listing);
let source = ReviewSource::pr(7);
{
let session = app.review.session_for_mut(&source);
let id = session.comments[0].id.clone();
session.reply(&id, "me", "local reply not yet sent");
session.comments[0].status = CommentStatus::Resolved;
}
app.sync_pr_comments(7, &listing);
let session = app.review.session_for(&source);
assert_eq!(session.comments.len(), 1);
assert_eq!(session.comments[0].status, CommentStatus::Open);
assert_eq!(session.comments[0].replies.len(), 1);
assert_eq!(
session.comments[0].replies[0].body,
"local reply not yet sent"
);
{
let session = app.review.session_for_mut(&source);
session.comments[0].status = CommentStatus::Resolved;
}
let id = app.review.session_for(&source).comments[0].id.clone();
app.pr_posts_inflight.insert(format!("res-{id}-true"));
app.sync_pr_comments(7, &listing);
assert_eq!(
app.review.session_for(&source).comments[0].status,
CommentStatus::Resolved
);
app.pr_posts_inflight.clear();
let resolved_listing = [PrComment {
resolved: true,
thread_id: Some("T_1".into()),
..listing[0].clone()
}];
{
let session = app.review.session_for_mut(&source);
session.comments[0].status = CommentStatus::Open;
}
app.pr_posts_inflight.insert(format!("res-{id}-false"));
app.sync_pr_comments(7, &resolved_listing);
assert_eq!(
app.review.session_for(&source).comments[0].status,
CommentStatus::Open,
"in-flight unresolve survives a stale poll"
);
}
#[test]
fn forge_resolved_threads_import_resolved_and_r_toggles_back() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
let head = app.review.vcs.resolve("HEAD").expect("head oid");
app.pr_ranges.insert(7, (head.clone(), head));
app.sync_pr_comments(
7,
&[PrComment {
id: "100".into(),
path: "app.txt".into(),
line: Some(2),
new_side: true,
body: "remote root".into(),
author: "alice".into(),
reply_to: None,
start_line: None,
thread_id: Some("T_1".into()),
resolved: true,
at: 10,
}],
);
let source = ReviewSource::pr(7);
assert_eq!(
app.review.session_for(&source).comments[0].status,
CommentStatus::Resolved,
"forge resolution imports"
);
app.queue_pr_resolve(7, "remote-100", false);
let PrPost::Resolve {
thread_id,
resolved,
..
} = &app.pending_pr_posts[0]
else {
panic!("expected a resolve post: {:?}", app.pending_pr_posts);
};
assert_eq!(thread_id, "T_1");
assert!(!resolved);
}
#[test]
fn sync_maps_threads_and_posting_stamps_remote_ids() {
let fixture = standard_fixture();
fixture.commit_all("pr head");
let mut app = App::new(fixture.review(), LoadedConfig::default());
let head = app.review.vcs.resolve("HEAD").expect("head oid");
let base = app.review.vcs.resolve("HEAD~1").expect("base oid");
app.pr_ranges.insert(7, (base.clone(), head.clone()));
app.sync_pr_comments(
7,
&[
PrComment {
id: "100".into(),
path: "app.txt".into(),
line: Some(2),
new_side: true,
body: "remote root".into(),
author: "alice".into(),
reply_to: None,
start_line: None,
thread_id: None,
resolved: false,
at: 10,
},
PrComment {
id: "101".into(),
path: "app.txt".into(),
line: Some(2),
new_side: true,
body: "remote reply".into(),
author: "bob".into(),
start_line: None,
thread_id: None,
resolved: false,
reply_to: Some("100".into()),
at: 11,
},
],
);
let source = ReviewSource::pr(7);
{
let session = app.review.session_for(&source);
assert_eq!(session.comments.len(), 1);
assert_eq!(session.comments[0].remote_id.as_deref(), Some("100"));
assert_eq!(session.comments[0].replies.len(), 1);
assert_eq!(session.comments[0].author, "alice");
}
let anchor = Anchor {
file: "app.txt".into(),
line: Some(3),
line_end: None,
on_old_side: false,
line_text: None,
};
let local_id = app
.review
.session_for_mut(&source)
.add_comment(anchor, "me", "needs work")
.id
.clone();
app.open_pr_diff(7, &base, &head);
assert!(app.diff.is_some(), "the PR diff view opened");
app.queue_pr_review(7, ReviewVerdict::Comment, "");
assert_eq!(app.pending_pr_posts.len(), 1);
app.queue_pr_review(7, ReviewVerdict::Comment, "");
assert_eq!(
app.pending_pr_posts.len(),
1,
"inflight set blocks re-queue"
);
let post = app.pending_pr_posts.remove(0);
app.on_pr_posted(&post, Ok(None));
let session = app.review.session_for(&source);
assert!(session.comments.iter().all(|c| c.id != local_id));
assert!(matches!(
app.pending_ci,
Some(crate::app::CiRequest::PrComments(7))
));
}
}