use anyhow::{Context, Result};
use octocrab::Octocrab;
use tracing::{debug, instrument};
use super::{ReferenceKind, parse_github_reference};
use crate::ai::types::{PrDetails, PrFile, PrReviewComment, ReviewEvent};
use crate::error::{AptuError, ResourceType};
use crate::triage::render_pr_review_comment_body;
#[derive(Debug, serde::Serialize)]
pub struct PrCreateResult {
pub pr_number: u64,
pub url: String,
pub branch: String,
pub base: String,
pub title: String,
pub draft: bool,
pub files_changed: u32,
pub additions: u64,
pub deletions: u64,
}
pub fn parse_pr_reference(
reference: &str,
repo_context: Option<&str>,
) -> Result<(String, String, u64)> {
parse_github_reference(ReferenceKind::Pull, reference, repo_context)
}
#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
#[allow(clippy::too_many_lines)]
pub async fn fetch_pr_details(
client: &Octocrab,
owner: &str,
repo: &str,
number: u64,
review_config: &crate::config::ReviewConfig,
) -> Result<PrDetails> {
debug!("Fetching PR details");
let pr = match client.pulls(owner, repo).get(number).await {
Ok(pr) => pr,
Err(e) => {
if let octocrab::Error::GitHub { source, .. } = &e
&& source.status_code == 404
{
if (client.issues(owner, repo).get(number).await).is_ok() {
return Err(AptuError::TypeMismatch {
number,
expected: ResourceType::PullRequest,
actual: ResourceType::Issue,
}
.into());
}
}
return Err(e)
.with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"));
}
};
let mut pr_files: Vec<PrFile> = Vec::new();
let mut page = client
.pulls(owner, repo)
.list_files(number)
.await
.with_context(|| format!("Failed to fetch files for PR #{number}"))?;
loop {
pr_files.extend(page.items.into_iter().map(|f| PrFile {
filename: f.filename,
status: format!("{:?}", f.status),
additions: f.additions,
deletions: f.deletions,
patch: f.patch,
patch_truncated: false,
full_content: None,
}));
if pr_files.len() >= 300 {
tracing::warn!(
"PR #{} has reached 300-file cap; stopping pagination",
number
);
pr_files.truncate(300);
break;
}
match client
.get_page::<octocrab::models::repos::DiffEntry>(&page.next)
.await
{
Ok(Some(next_page)) => page = next_page,
Ok(None) => break,
Err(e) => {
tracing::warn!("Error fetching next page of files: {}", e);
break;
}
}
}
for file in &mut pr_files {
#[allow(clippy::collapsible_if)]
if let Some(patch) = &file.patch {
if is_patch_truncated(patch) {
file.patch_truncated = true;
if let Ok(Some(content)) = fetch_file_contents_single(
client,
owner,
repo,
&file.filename,
&pr.head.sha,
review_config.max_chars_per_file,
)
.await
{
file.patch = Some(content);
}
}
}
}
let file_contents = fetch_file_contents(
client,
owner,
repo,
&pr_files,
&pr.head.sha,
review_config.max_full_content_files,
review_config.max_chars_per_file,
)
.await;
debug_assert_eq!(
pr_files.len(),
file_contents.len(),
"fetch_file_contents must return one entry per file"
);
let pr_files: Vec<PrFile> = pr_files
.into_iter()
.zip(file_contents)
.map(|(mut file, content)| {
file.full_content = content;
file
})
.collect();
let labels: Vec<String> = pr.labels.iter().map(|l| l.name.clone()).collect();
let details = PrDetails {
owner: owner.to_string(),
repo: repo.to_string(),
number,
title: pr.title.clone(),
body: pr.body.clone().unwrap_or_default(),
base_branch: pr.base.ref_field,
head_branch: pr.head.ref_field,
head_sha: pr.head.sha,
files: pr_files,
url: pr.html_url.to_string(),
labels,
review_comments: Vec::new(),
instructions: None,
};
debug!(
file_count = details.files.len(),
"PR details fetched successfully"
);
Ok(details)
}
fn is_patch_truncated(patch: &str) -> bool {
let lines: Vec<&str> = patch.lines().collect();
if let Some(last_line) = lines.iter().rev().find(|line| !line.trim().is_empty())
&& (last_line.starts_with('+') || last_line.starts_with('-'))
{
return true;
}
if let Some(last_hunk_header) = lines.iter().rev().find(|line| line.contains("@@")) {
if let Some(plus_part) = last_hunk_header.split('+').nth(1) {
if let Some(size_str) = plus_part.split_whitespace().next() {
if let Some(count_str) = size_str.split(',').nth(1)
&& let Ok(declared_count) = count_str.parse::<usize>()
{
if let Some(hunk_idx) = lines.iter().position(|&line| line == *last_hunk_header)
{
let lines_after_hunk = &lines[hunk_idx + 1..];
let mut actual_count = 0;
for line in lines_after_hunk {
if line.starts_with("@@") {
break;
}
if line.starts_with(' ')
|| line.starts_with('+')
|| line.starts_with('-')
{
actual_count += 1;
}
}
if actual_count < declared_count {
return true;
}
}
}
}
}
}
false
}
async fn fetch_file_contents_single(
client: &Octocrab,
owner: &str,
repo: &str,
filename: &str,
head_sha: &str,
max_chars: usize,
) -> Result<Option<String>> {
match client
.repos(owner, repo)
.get_content()
.path(filename)
.r#ref(head_sha)
.send()
.await
{
Ok(content) => {
if let Some(item) = content.items.first() {
if let Some(decoded) = item.decoded_content() {
let truncated = if decoded.len() > max_chars {
decoded.chars().take(max_chars).collect::<String>()
} else {
decoded
};
Ok(Some(truncated))
} else {
tracing::warn!(
"Failed to decode content for {}/{}/{} at {}",
owner,
repo,
filename,
head_sha
);
Ok(None)
}
} else {
tracing::warn!(
"File content response was empty for {}/{}/{} at {}",
owner,
repo,
filename,
head_sha
);
Ok(None)
}
}
Err(e) => {
tracing::warn!(
"Failed to fetch content for {}/{}/{} at {}: {}",
owner,
repo,
filename,
head_sha,
e
);
Ok(None)
}
}
}
#[instrument(skip(client, files), fields(owner = %owner, repo = %repo, max_files = max_files))]
async fn fetch_file_contents(
client: &Octocrab,
owner: &str,
repo: &str,
files: &[PrFile],
head_sha: &str,
max_files: usize,
max_chars_per_file: usize,
) -> Vec<Option<String>> {
let mut results = Vec::with_capacity(files.len());
let mut fetched_count = 0usize;
for file in files {
if should_skip_file(&file.filename, &file.status, file.patch.as_ref()) {
results.push(None);
continue;
}
if fetched_count >= max_files {
debug!(
file = %file.filename,
fetched_count = fetched_count,
max_files = max_files,
"Fetched file count exceeds max_files cap"
);
results.push(None);
continue;
}
match client
.repos(owner, repo)
.get_content()
.path(&file.filename)
.r#ref(head_sha)
.send()
.await
{
Ok(content) => {
if let Some(item) = content.items.first() {
if let Some(decoded) = item.decoded_content() {
let truncated = if decoded.len() > max_chars_per_file {
decoded.chars().take(max_chars_per_file).collect::<String>()
} else {
decoded
};
debug!(
file = %file.filename,
content_len = truncated.len(),
"File content fetched and truncated"
);
results.push(Some(truncated));
fetched_count += 1;
} else {
tracing::warn!(
file = %file.filename,
"Failed to decode file content; skipping"
);
results.push(None);
}
} else {
tracing::warn!(
file = %file.filename,
"File content response was empty; skipping"
);
results.push(None);
}
}
Err(e) => {
tracing::warn!(
file = %file.filename,
err = %e,
"Failed to fetch file content; skipping"
);
results.push(None);
}
}
}
results
}
#[allow(clippy::too_many_arguments)]
#[instrument(skip(client, comments), fields(owner = %owner, repo = %repo, number = number, event = %event))]
pub async fn post_pr_review(
client: &Octocrab,
owner: &str,
repo: &str,
number: u64,
body: &str,
event: ReviewEvent,
comments: &[PrReviewComment],
commit_id: &str,
) -> Result<u64> {
debug!("Posting PR review");
let route = format!("/repos/{owner}/{repo}/pulls/{number}/reviews");
let inline_comments: Vec<serde_json::Value> = comments
.iter()
.filter_map(|c| {
c.line.map(|line| {
serde_json::json!({
"path": c.file,
"line": line,
"side": "RIGHT",
"body": render_pr_review_comment_body(c),
})
})
})
.collect();
let mut payload = serde_json::json!({
"body": body,
"event": event.to_string(),
"comments": inline_comments,
});
if !commit_id.is_empty() {
payload["commit_id"] = serde_json::Value::String(commit_id.to_string());
}
#[derive(serde::Deserialize)]
struct ReviewResponse {
id: u64,
}
let response: ReviewResponse = client.post(route, Some(&payload)).await.with_context(|| {
format!(
"Failed to post review to PR #{number} in {owner}/{repo}. \
Check that you have write access to the repository."
)
})?;
debug!(review_id = response.id, "PR review posted successfully");
Ok(response.id)
}
#[instrument(skip(client), fields(owner = %owner, repo = %repo, comment_id = comment_id))]
pub async fn delete_pr_review_comment(
client: &Octocrab,
owner: &str,
repo: &str,
comment_id: u64,
) -> Result<()> {
debug!("Deleting PR review comment");
let route = format!("/repos/{owner}/{repo}/pulls/comments/{comment_id}");
let empty_body = serde_json::json!({});
let result: std::result::Result<serde_json::Value, _> =
client.delete(&route, Some(&empty_body)).await;
match result {
Ok(_) => {
debug!("PR review comment deleted successfully");
Ok(())
}
Err(e)
if let octocrab::Error::GitHub { source, .. } = &e
&& source.status_code.as_u16() == 404 =>
{
debug!("PR review comment already deleted (404); treating as success");
Ok(())
}
Err(e) => {
Err(e).with_context(|| format!("Failed to delete PR review comment #{comment_id}"))
}
}
}
#[must_use]
pub fn labels_from_pr_metadata(title: &str, file_paths: &[String]) -> Vec<String> {
let mut labels = std::collections::HashSet::new();
let prefix = title
.split(':')
.next()
.unwrap_or("")
.split('(')
.next()
.unwrap_or("")
.trim();
let type_label = match prefix {
"feat" | "perf" => Some("enhancement"),
"fix" => Some("bug"),
"docs" => Some("documentation"),
"refactor" => Some("refactor"),
_ => None,
};
if let Some(label) = type_label {
labels.insert(label.to_string());
}
for path in file_paths {
let scope = if path.starts_with("crates/aptu-cli/") {
Some("cli")
} else if path.starts_with("docs/") {
Some("documentation")
} else {
None
};
if let Some(label) = scope {
labels.insert(label.to_string());
}
}
labels.into_iter().collect()
}
#[instrument(skip(client), fields(owner = %owner, repo = %repo, head = %head_branch, base = %base_branch))]
#[allow(clippy::too_many_arguments)]
pub async fn create_pull_request(
client: &Octocrab,
owner: &str,
repo: &str,
title: &str,
head_branch: &str,
base_branch: &str,
body: Option<&str>,
draft: bool,
) -> anyhow::Result<PrCreateResult> {
debug!("Creating pull request");
let pr = client
.pulls(owner, repo)
.create(title, head_branch, base_branch)
.body(body.unwrap_or_default())
.draft(draft)
.send()
.await
.with_context(|| {
format!("Failed to create PR in {owner}/{repo} ({head_branch} -> {base_branch})")
})?;
let result = PrCreateResult {
pr_number: pr.number,
url: pr.html_url.to_string(),
branch: pr.head.ref_field,
base: pr.base.ref_field,
title: pr.title.clone(),
draft: pr.draft.unwrap_or(false),
files_changed: u32::try_from(pr.changed_files).unwrap_or(u32::MAX),
additions: pr.additions,
deletions: pr.deletions,
};
debug!(
pr_number = result.pr_number,
"Pull request created successfully"
);
Ok(result)
}
fn should_skip_file(filename: &str, status: &str, patch: Option<&String>) -> bool {
if status.to_lowercase().contains("removed") {
debug!(file = %filename, "Skipping removed file");
return true;
}
if patch.is_none_or(String::is_empty) {
debug!(file = %filename, "Skipping file with empty patch");
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai::types::CommentSeverity;
fn decode_content(encoded: &str, max_chars: usize) -> Option<String> {
use base64::Engine;
let engine = base64::engine::general_purpose::STANDARD;
let decoded_bytes = engine.decode(encoded).ok()?;
let decoded_str = String::from_utf8(decoded_bytes).ok()?;
if decoded_str.len() <= max_chars {
Some(decoded_str)
} else {
Some(decoded_str.chars().take(max_chars).collect::<String>())
}
}
#[test]
fn test_pr_create_result_fields() {
let result = PrCreateResult {
pr_number: 42,
url: "https://github.com/owner/repo/pull/42".to_string(),
branch: "feat/my-feature".to_string(),
base: "main".to_string(),
title: "feat: add feature".to_string(),
draft: false,
files_changed: 3,
additions: 100,
deletions: 10,
};
assert_eq!(result.pr_number, 42);
assert_eq!(result.url, "https://github.com/owner/repo/pull/42");
assert_eq!(result.branch, "feat/my-feature");
assert_eq!(result.base, "main");
assert_eq!(result.title, "feat: add feature");
assert!(!result.draft);
assert_eq!(result.files_changed, 3);
assert_eq!(result.additions, 100);
assert_eq!(result.deletions, 10);
}
fn build_inline_comments(comments: &[PrReviewComment]) -> Vec<serde_json::Value> {
comments
.iter()
.filter_map(|c| {
c.line.map(|line| {
serde_json::json!({
"path": c.file,
"line": line,
"side": "RIGHT",
"body": render_pr_review_comment_body(c),
})
})
})
.collect()
}
#[test]
fn test_post_pr_review_payload_with_comments() {
let comments = vec![PrReviewComment {
file: "src/main.rs".to_string(),
line: Some(42),
comment: "Consider using a match here.".to_string(),
severity: CommentSeverity::Suggestion,
suggested_code: None,
}];
let inline = build_inline_comments(&comments);
assert_eq!(inline.len(), 1);
assert_eq!(inline[0]["path"], "src/main.rs");
assert_eq!(inline[0]["line"], 42);
assert_eq!(inline[0]["side"], "RIGHT");
assert_eq!(inline[0]["body"], "Consider using a match here.");
}
#[test]
fn test_post_pr_review_skips_none_line_comments() {
let comments = vec![
PrReviewComment {
file: "src/lib.rs".to_string(),
line: None,
comment: "General file comment.".to_string(),
severity: CommentSeverity::Info,
suggested_code: None,
},
PrReviewComment {
file: "src/lib.rs".to_string(),
line: Some(10),
comment: "Inline comment.".to_string(),
severity: CommentSeverity::Warning,
suggested_code: None,
},
];
let inline = build_inline_comments(&comments);
assert_eq!(inline.len(), 1);
assert_eq!(inline[0]["line"], 10);
}
#[test]
fn test_post_pr_review_empty_comments() {
let comments: Vec<PrReviewComment> = vec![];
let inline = build_inline_comments(&comments);
assert!(inline.is_empty());
let serialized = serde_json::to_string(&inline).unwrap();
assert_eq!(serialized, "[]");
}
#[test]
fn test_parse_pr_reference_delegates_to_shared() {
let (owner, repo, number) =
parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
assert_eq!(owner, "block");
assert_eq!(repo, "goose");
assert_eq!(number, 123);
}
#[test]
fn test_title_prefix_to_label_mapping() {
let cases = vec![
(
"feat: add new feature",
vec!["enhancement"],
"feat should map to enhancement",
),
("fix: resolve bug", vec!["bug"], "fix should map to bug"),
(
"docs: update readme",
vec!["documentation"],
"docs should map to documentation",
),
(
"refactor: improve code",
vec!["refactor"],
"refactor should map to refactor",
),
(
"perf: optimize",
vec!["enhancement"],
"perf should map to enhancement",
),
(
"chore: update deps",
vec![],
"chore should produce no labels",
),
];
for (title, expected_labels, msg) in cases {
let labels = labels_from_pr_metadata(title, &[]);
for expected in &expected_labels {
assert!(
labels.contains(&expected.to_string()),
"{msg}: expected '{expected}' in {labels:?}",
);
}
if expected_labels.is_empty() {
assert!(labels.is_empty(), "{msg}: expected empty, got {labels:?}");
}
}
}
#[test]
fn test_file_path_to_scope_mapping() {
let cases = vec![
(
"feat: cli",
vec!["crates/aptu-cli/src/main.rs"],
vec!["enhancement", "cli"],
"cli path should map to cli scope",
),
(
"feat: docs",
vec!["docs/GITHUB_ACTION.md"],
vec!["enhancement", "documentation"],
"docs path should map to documentation scope",
),
(
"feat: workflow",
vec![".github/workflows/test.yml"],
vec!["enhancement"],
"workflow path should be ignored",
),
];
for (title, paths, expected_labels, msg) in cases {
let labels = labels_from_pr_metadata(
title,
&paths
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>(),
);
for expected in expected_labels {
assert!(
labels.contains(&expected.to_string()),
"{msg}: expected '{expected}' in {labels:?}",
);
}
}
}
#[test]
fn test_combined_title_and_paths() {
let labels = labels_from_pr_metadata(
"feat: multi",
&[
"crates/aptu-cli/src/main.rs".to_string(),
"docs/README.md".to_string(),
],
);
assert!(
labels.contains(&"enhancement".to_string()),
"should include enhancement from feat prefix"
);
assert!(
labels.contains(&"cli".to_string()),
"should include cli from path"
);
assert!(
labels.contains(&"documentation".to_string()),
"should include documentation from path"
);
}
#[test]
fn test_no_match_returns_empty() {
let cases = vec![
(
"Random title",
vec![],
"unrecognized prefix should return empty",
),
(
"chore: update",
vec![],
"ignored prefix should return empty",
),
];
for (title, paths, msg) in cases {
let labels = labels_from_pr_metadata(title, &paths);
assert!(labels.is_empty(), "{msg}: got {labels:?}");
}
}
#[test]
fn test_scoped_prefix_extracts_type() {
let labels = labels_from_pr_metadata("feat(cli): add new feature", &[]);
assert!(
labels.contains(&"enhancement".to_string()),
"scoped prefix should extract type from feat(cli)"
);
}
#[test]
fn test_duplicate_labels_deduplicated() {
let labels = labels_from_pr_metadata("docs: update", &["docs/README.md".to_string()]);
assert_eq!(
labels.len(),
1,
"should have exactly one label when title and path both map to documentation"
);
assert!(
labels.contains(&"documentation".to_string()),
"should contain documentation label"
);
}
#[test]
fn test_should_skip_file_respects_fetched_count_cap() {
let removed_file = PrFile {
filename: "removed.rs".to_string(),
status: "removed".to_string(),
additions: 0,
deletions: 5,
patch: None,
patch_truncated: false,
full_content: None,
};
let modified_file = PrFile {
filename: "file_0.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("+ new code".to_string()),
patch_truncated: false,
full_content: None,
};
let no_patch_file = PrFile {
filename: "file_1.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
patch_truncated: false,
full_content: None,
};
assert!(
should_skip_file(
&removed_file.filename,
&removed_file.status,
removed_file.patch.as_ref()
),
"removed files should be skipped"
);
assert!(
!should_skip_file(
&modified_file.filename,
&modified_file.status,
modified_file.patch.as_ref()
),
"modified files with patch should not be skipped"
);
assert!(
should_skip_file(
&no_patch_file.filename,
&no_patch_file.status,
no_patch_file.patch.as_ref()
),
"files without patch should be skipped"
);
}
#[test]
fn test_decode_content_valid_base64() {
use base64::Engine;
let engine = base64::engine::general_purpose::STANDARD;
let original = "Hello, World!";
let encoded = engine.encode(original);
let result = decode_content(&encoded, 1000);
assert_eq!(
result,
Some(original.to_string()),
"valid base64 should decode successfully"
);
}
#[test]
fn test_decode_content_invalid_base64() {
let invalid_base64 = "!!!invalid!!!";
let result = decode_content(invalid_base64, 1000);
assert_eq!(result, None, "invalid base64 should return None");
}
#[test]
fn test_decode_content_truncates_at_max_chars() {
use base64::Engine;
let engine = base64::engine::general_purpose::STANDARD;
let original = "こんにちは".repeat(10); let encoded = engine.encode(&original);
let max_chars = 10;
let result = decode_content(&encoded, max_chars);
assert!(result.is_some(), "decoding should succeed");
let decoded = result.unwrap();
assert_eq!(
decoded.chars().count(),
max_chars,
"output should be truncated to max_chars on character boundary"
);
assert!(
decoded.is_char_boundary(decoded.len()),
"output should be valid UTF-8 (truncated on char boundary)"
);
}
#[test]
fn test_list_files_pagination_collects_all_pages() {
let mut page1_items = Vec::new();
for i in 0..100 {
page1_items.push(PrFile {
filename: format!("file{}.rs", i),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
patch_truncated: false,
full_content: None,
});
}
let mut page2_items = Vec::new();
for i in 100..150 {
page2_items.push(PrFile {
filename: format!("file{}.rs", i),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
patch_truncated: false,
full_content: None,
});
}
let mut all_files = Vec::new();
all_files.extend(page1_items);
all_files.extend(page2_items);
assert_eq!(
all_files.len(),
150,
"pagination should collect all items from both pages"
);
}
#[test]
fn test_list_files_pagination_respects_300_file_cap() {
let mut files = Vec::new();
for i in 0..301 {
files.push(PrFile {
filename: format!("file{}.rs", i),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
patch_truncated: false,
full_content: None,
});
}
if files.len() >= 300 {
files.truncate(300);
}
assert_eq!(files.len(), 300, "pagination should enforce 300-file cap");
}
#[test]
fn test_is_patch_truncated_detects_mid_hunk_plus() {
let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n+";
assert!(
is_patch_truncated(truncated_patch),
"patch ending with + should be detected as truncated"
);
}
#[test]
fn test_is_patch_truncated_detects_mid_hunk_minus() {
let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2\n-";
assert!(
is_patch_truncated(truncated_patch),
"patch ending with - should be detected as truncated"
);
}
#[test]
fn test_is_patch_truncated_clean_patch_context_line() {
let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
assert!(
!is_patch_truncated(clean_patch),
"patch ending with context line should not be detected as truncated"
);
}
#[test]
fn test_is_patch_truncated_correct_hunk_line_count() {
let clean_patch = "@@ -1,3 +1,3 @@\n line1\n line2\n line3";
assert!(
!is_patch_truncated(clean_patch),
"patch with correct hunk line count should not be detected as truncated"
);
}
#[test]
fn test_is_patch_truncated_declared_hunk_size_larger_than_delivered() {
let truncated_patch = "@@ -1,3 +1,4 @@\n line1\n line2";
assert!(
is_patch_truncated(truncated_patch),
"patch with declared hunk size larger than delivered should be detected as truncated"
);
}
#[test]
fn test_is_patch_truncated_no_hunk_header_but_last_line_plus() {
let truncated_patch = "line1\nline2\n+";
assert!(
is_patch_truncated(truncated_patch),
"patch with no @@ header but ending with + should be detected as truncated"
);
}
#[test]
fn test_is_patch_truncated_empty_patch() {
let empty_patch = "";
assert!(
!is_patch_truncated(empty_patch),
"empty patch should not be detected as truncated"
);
}
#[test]
fn test_is_patch_truncated_multiple_hunks_last_hunk_truncated() {
let truncated_patch = "@@ -1,2 +1,2 @@\n line1\n line2\n@@ -5,3 +5,4 @@\n line5\n line6";
assert!(
is_patch_truncated(truncated_patch),
"patch with last hunk truncated should be detected as truncated"
);
}
#[test]
fn test_fetch_file_contents_fallback_on_truncated_patch() {
}
}