aptu_core/github/
pulls.rs1use anyhow::{Context, Result, bail};
9use octocrab::Octocrab;
10use tracing::{debug, instrument};
11
12use crate::ai::types::{PrDetails, PrFile};
13
14pub fn parse_pr_reference(
34 reference: &str,
35 repo_context: Option<&str>,
36) -> Result<(String, String, u64)> {
37 let reference = reference.trim();
38
39 if reference.starts_with("https://github.com/") || reference.starts_with("http://github.com/") {
42 let path = reference
43 .trim_start_matches("https://github.com/")
44 .trim_start_matches("http://github.com/");
45
46 let parts: Vec<&str> = path.split('/').collect();
47 if parts.len() >= 4 && parts[2] == "pull" {
48 let owner = parts[0].to_string();
49 let repo = parts[1].to_string();
50 let number: u64 = parts[3]
51 .parse()
52 .with_context(|| format!("Invalid PR number in URL: {}", parts[3]))?;
53 return Ok((owner, repo, number));
54 }
55 bail!("Invalid GitHub PR URL format: {reference}");
56 }
57
58 if let Some((repo_part, num_part)) = reference.split_once('#') {
60 if let Some((owner, repo)) = repo_part.split_once('/') {
61 let number: u64 = num_part
62 .parse()
63 .with_context(|| format!("Invalid PR number: {num_part}"))?;
64 return Ok((owner.to_string(), repo.to_string(), number));
65 }
66 if let Some(ctx) = repo_context
68 && let Some((owner, repo)) = ctx.split_once('/')
69 {
70 let number: u64 = num_part
71 .parse()
72 .with_context(|| format!("Invalid PR number: {num_part}"))?;
73 return Ok((owner.to_string(), repo.to_string(), number));
74 }
75 bail!("Invalid PR reference format: {reference}");
76 }
77
78 if let Ok(number) = reference.parse::<u64>() {
80 if let Some(ctx) = repo_context {
81 if let Some((owner, repo)) = ctx.split_once('/') {
82 return Ok((owner.to_string(), repo.to_string(), number));
83 }
84 bail!("Invalid repo_context format, expected 'owner/repo': {ctx}");
85 }
86 bail!("Bare PR number requires --repo flag or default_repo config: {reference}");
87 }
88
89 bail!(
90 "Invalid PR reference format: {reference}. Expected URL, owner/repo#number, or number with --repo"
91 )
92}
93
94#[instrument(skip(client), fields(owner = %owner, repo = %repo, number = number))]
113pub async fn fetch_pr_details(
114 client: &Octocrab,
115 owner: &str,
116 repo: &str,
117 number: u64,
118) -> Result<PrDetails> {
119 debug!("Fetching PR details");
120
121 let pr = client
123 .pulls(owner, repo)
124 .get(number)
125 .await
126 .with_context(|| format!("Failed to fetch PR #{number} from {owner}/{repo}"))?;
127
128 let files = client
130 .pulls(owner, repo)
131 .list_files(number)
132 .await
133 .with_context(|| format!("Failed to fetch files for PR #{number}"))?;
134
135 let pr_files: Vec<PrFile> = files
137 .items
138 .into_iter()
139 .map(|f| PrFile {
140 filename: f.filename,
141 status: format!("{:?}", f.status),
142 additions: f.additions,
143 deletions: f.deletions,
144 patch: f.patch,
145 })
146 .collect();
147
148 let details = PrDetails {
149 owner: owner.to_string(),
150 repo: repo.to_string(),
151 number,
152 title: pr.title.unwrap_or_default(),
153 body: pr.body.unwrap_or_default(),
154 base_branch: pr.base.ref_field,
155 head_branch: pr.head.ref_field,
156 files: pr_files,
157 url: pr.html_url.map_or_else(String::new, |u| u.to_string()),
158 };
159
160 debug!(
161 file_count = details.files.len(),
162 "PR details fetched successfully"
163 );
164
165 Ok(details)
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn test_parse_pr_reference_full_url() {
174 let (owner, repo, number) =
175 parse_pr_reference("https://github.com/block/goose/pull/123", None).unwrap();
176 assert_eq!(owner, "block");
177 assert_eq!(repo, "goose");
178 assert_eq!(number, 123);
179 }
180
181 #[test]
182 fn test_parse_pr_reference_short_form() {
183 let (owner, repo, number) = parse_pr_reference("block/goose#456", None).unwrap();
184 assert_eq!(owner, "block");
185 assert_eq!(repo, "goose");
186 assert_eq!(number, 456);
187 }
188
189 #[test]
190 fn test_parse_pr_reference_bare_number_with_context() {
191 let (owner, repo, number) = parse_pr_reference("789", Some("block/goose")).unwrap();
192 assert_eq!(owner, "block");
193 assert_eq!(repo, "goose");
194 assert_eq!(number, 789);
195 }
196
197 #[test]
198 fn test_parse_pr_reference_bare_number_without_context() {
199 let result = parse_pr_reference("123", None);
200 assert!(result.is_err());
201 assert!(
202 result
203 .unwrap_err()
204 .to_string()
205 .contains("requires --repo flag")
206 );
207 }
208
209 #[test]
210 fn test_parse_pr_reference_hash_with_context() {
211 let (owner, repo, number) = parse_pr_reference("#42", Some("owner/repo")).unwrap();
212 assert_eq!(owner, "owner");
213 assert_eq!(repo, "repo");
214 assert_eq!(number, 42);
215 }
216
217 #[test]
218 fn test_parse_pr_reference_invalid_url() {
219 let result = parse_pr_reference("https://github.com/invalid", None);
220 assert!(result.is_err());
221 }
222
223 #[test]
224 fn test_parse_pr_reference_invalid_number() {
225 let result = parse_pr_reference("block/goose#abc", None);
226 assert!(result.is_err());
227 }
228}