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};
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))]
pub async fn fetch_pr_details(
client: &Octocrab,
owner: &str,
repo: &str,
number: u64,
) -> 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 files = client
.pulls(owner, repo)
.list_files(number)
.await
.with_context(|| format!("Failed to fetch files for PR #{number}"))?;
let pr_files: Vec<PrFile> = files
.items
.into_iter()
.map(|f| PrFile {
filename: f.filename,
status: format!("{:?}", f.status),
additions: f.additions,
deletions: f.deletions,
patch: f.patch,
})
.collect();
let labels: Vec<String> = pr
.labels
.iter()
.flat_map(|labels_vec| labels_vec.iter().map(|l| l.name.clone()))
.collect();
let details = PrDetails {
owner: owner.to_string(),
repo: repo.to_string(),
number,
title: pr.title.unwrap_or_default(),
body: pr.body.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.map_or_else(String::new, |u| u.to_string()),
labels,
};
debug!(
file_count = details.files.len(),
"PR details fetched successfully"
);
Ok(details)
}
#[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": c.comment,
})
})
})
.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)
}
#[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("crates/aptu-ffi/") || path.starts_with("AptuApp/") {
Some("ios")
} else if path.starts_with("docs/") {
Some("documentation")
} else if path.starts_with("snap/") {
Some("distribution")
} else {
None
};
if let Some(label) = scope {
labels.insert(label.to_string());
}
}
labels.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai::types::CommentSeverity;
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": c.comment,
})
})
})
.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,
}];
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,
},
PrReviewComment {
file: "src/lib.rs".to_string(),
line: Some(10),
comment: "Inline comment.".to_string(),
severity: CommentSeverity::Warning,
},
];
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: ios",
vec!["crates/aptu-ffi/src/lib.rs"],
vec!["enhancement", "ios"],
"ffi path should map to ios scope",
),
(
"feat: ios",
vec!["AptuApp/ContentView.swift"],
vec!["enhancement", "ios"],
"app path should map to ios scope",
),
(
"feat: docs",
vec!["docs/GITHUB_ACTION.md"],
vec!["enhancement", "documentation"],
"docs path should map to documentation scope",
),
(
"feat: snap",
vec!["snap/snapcraft.yaml"],
vec!["enhancement", "distribution"],
"snap path should map to distribution 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"
);
}
}