1use crate::git::GitRepo;
2use crate::types::{Finding, Review as CodeReview};
3use anyhow::{Context, Result, anyhow, bail};
4use octocrab::models::pulls::{PullRequest, Review as GitHubReview, ReviewAction};
5use octocrab::{Octocrab, params};
6use regex::Regex;
7use serde_json::Value;
8use std::collections::{HashMap, HashSet};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::sync::LazyLock;
12use url::Url;
13
14static BACKTICK_LOCATION_RE: LazyLock<Regex> = LazyLock::new(|| {
15 Regex::new(r"`([^`\s]+):(\d+)`").expect("backtick location regex should compile")
16});
17static PLAIN_LOCATION_RE: LazyLock<Regex> = LazyLock::new(|| {
18 Regex::new(r"([A-Za-z0-9_./-]+\.[A-Za-z0-9_-]+):(\d+)")
19 .expect("plain location regex should compile")
20});
21static HUNK_RE: LazyLock<Regex> = LazyLock::new(|| {
22 Regex::new(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
23 .expect("unified diff hunk regex should compile")
24});
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct GitHubRepository {
28 pub owner: String,
29 pub name: String,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct PullRequestTemplate {
34 pub path: String,
35 pub body: String,
36}
37
38#[derive(Debug, Clone, Copy)]
39pub struct ReviewPublishOptions {
40 pub event: ReviewAction,
41 pub inline_comments: bool,
42}
43
44pub struct GitHubClient {
45 crab: Octocrab,
46 repo: GitHubRepository,
47}
48
49impl GitHubClient {
50 pub fn from_git_repo(repo: &GitRepo) -> Result<Self> {
51 let remote_url = github_remote_url(repo)?;
52 let github_repo = GitHubRepository::parse(&remote_url)?;
53 let token =
54 gh_token::get().map_err(|e| anyhow!("GitHub authentication unavailable: {e}"))?;
55 let crab = Octocrab::builder()
56 .personal_token(token)
57 .build()
58 .context("Failed to initialize GitHub client")?;
59
60 Ok(Self {
61 crab,
62 repo: github_repo,
63 })
64 }
65
66 pub async fn resolve_pull_number(
67 &self,
68 explicit_pull_number: Option<u64>,
69 git_repo: &GitRepo,
70 ) -> Result<u64> {
71 if let Some(number) = explicit_pull_number {
72 return Ok(number);
73 }
74
75 let branch = git_repo
76 .get_current_branch()
77 .context("Could not infer PR: failed to read current branch")?;
78 if branch == "HEAD detached" {
79 bail!("Could not infer PR from a detached HEAD; pass --pr <number>");
80 }
81
82 self.find_open_pull_for_branch(&branch).await
83 }
84
85 pub async fn update_pull_body(&self, pull_number: u64, body: &str) -> Result<PullRequest> {
86 self.crab
87 .pulls(&self.repo.owner, &self.repo.name)
88 .update(pull_number)
89 .body(body)
90 .send()
91 .await
92 .with_context(|| format!("Failed to update PR #{pull_number}"))
93 }
94
95 pub async fn pull_body(&self, pull_number: u64) -> Result<String> {
96 let pull = self
97 .crab
98 .pulls(&self.repo.owner, &self.repo.name)
99 .get(pull_number)
100 .await
101 .with_context(|| format!("Failed to fetch PR #{pull_number}"))?;
102
103 Ok(pull.body.unwrap_or_default())
104 }
105
106 pub async fn publish_review(
107 &self,
108 pull_number: u64,
109 body: &str,
110 options: ReviewPublishOptions,
111 ) -> Result<GitHubReview> {
112 self.publish_review_with_comments(pull_number, ReviewSource::Markdown(body), options)
113 .await
114 }
115
116 pub async fn publish_structured_review(
117 &self,
118 pull_number: u64,
119 review: &CodeReview,
120 options: ReviewPublishOptions,
121 ) -> Result<GitHubReview> {
122 self.publish_review_with_comments(pull_number, ReviewSource::Structured(review), options)
123 .await
124 }
125
126 async fn publish_review_with_comments(
127 &self,
128 pull_number: u64,
129 review: ReviewSource<'_>,
130 options: ReviewPublishOptions,
131 ) -> Result<GitHubReview> {
132 let pull = self
133 .crab
134 .pulls(&self.repo.owner, &self.repo.name)
135 .get(pull_number)
136 .await
137 .with_context(|| format!("Failed to fetch PR #{pull_number}"))?;
138 let review_body = review.body(&self.repo, &pull.head.sha);
139 let comments = if options.inline_comments {
140 self.validated_inline_comments(pull_number, review).await?
141 } else {
142 Vec::new()
143 };
144
145 let route = format!(
146 "/repos/{owner}/{repo}/pulls/{pull_number}/reviews",
147 owner = self.repo.owner,
148 repo = self.repo.name,
149 );
150 let payload = serde_json::json!({
151 "body": review_body,
152 "event": options.event,
153 "commit_id": pull.head.sha,
154 "comments": comments,
155 });
156
157 self.crab
158 .post(route, Some(&payload))
159 .await
160 .with_context(|| format!("Failed to publish review on PR #{pull_number}"))
161 }
162
163 pub fn repo(&self) -> &GitHubRepository {
164 &self.repo
165 }
166
167 async fn find_open_pull_for_branch(&self, branch: &str) -> Result<u64> {
168 let same_repo_head = format!("{}:{branch}", self.repo.owner);
169 let page = self
170 .crab
171 .pulls(&self.repo.owner, &self.repo.name)
172 .list()
173 .state(params::State::Open)
174 .head(same_repo_head)
175 .per_page(10)
176 .send()
177 .await
178 .with_context(|| format!("Failed to search open PRs for branch `{branch}`"))?;
179
180 if let Some(number) = single_pull_number(&page.items) {
181 return Ok(number);
182 }
183
184 let page = self
185 .crab
186 .pulls(&self.repo.owner, &self.repo.name)
187 .list()
188 .state(params::State::Open)
189 .per_page(100)
190 .send()
191 .await
192 .context("Failed to list open PRs")?;
193 let matches: Vec<&PullRequest> = page
194 .items
195 .iter()
196 .filter(|pull| pull.head.ref_field == branch)
197 .collect();
198
199 match matches.as_slice() {
200 [pull] => Ok(pull.number),
201 [] => bail!("No open GitHub PR found for branch `{branch}`; pass --pr <number>"),
202 _ => bail!("Multiple open GitHub PRs found for branch `{branch}`; pass --pr <number>"),
203 }
204 }
205
206 async fn validated_inline_comments(
207 &self,
208 pull_number: u64,
209 review: ReviewSource<'_>,
210 ) -> Result<Vec<Value>> {
211 let diff = self
212 .crab
213 .pulls(&self.repo.owner, &self.repo.name)
214 .get_diff(pull_number)
215 .await
216 .with_context(|| format!("Failed to fetch PR #{pull_number} diff"))?;
217 let reviewable_lines = parse_reviewable_lines(&diff);
218 let candidates = review.inline_comment_candidates();
219
220 Ok(candidates
221 .into_iter()
222 .filter(|candidate| candidate.is_reviewable(&reviewable_lines))
223 .map(|candidate| {
224 let mut comment = serde_json::json!({
225 "path": candidate.path,
226 "line": candidate.line,
227 "side": "RIGHT",
228 "body": candidate.body,
229 });
230 if let Some(start_line) = candidate.start_line
231 && let Some(object) = comment.as_object_mut()
232 {
233 object.insert("start_line".to_string(), serde_json::json!(start_line));
234 object.insert("start_side".to_string(), serde_json::json!("RIGHT"));
235 }
236 comment
237 })
238 .collect())
239 }
240}
241
242#[derive(Debug, Clone, Copy)]
243enum ReviewSource<'a> {
244 Markdown(&'a str),
245 Structured(&'a CodeReview),
246}
247
248impl ReviewSource<'_> {
249 fn body(self, repo: &GitHubRepository, sha: &str) -> String {
250 match self {
251 Self::Markdown(body) => body.to_string(),
252 Self::Structured(review) => review_body_with_permalinks(repo, review, sha),
253 }
254 }
255
256 fn inline_comment_candidates(self) -> Vec<InlineCommentCandidate> {
257 match self {
258 Self::Markdown(body) => extract_inline_comment_candidates(body),
259 Self::Structured(review) => extract_structured_inline_comment_candidates(review),
260 }
261 }
262}
263
264pub fn find_pull_request_template(repo_root: &Path) -> Result<Option<PullRequestTemplate>> {
265 for path in singular_template_paths(repo_root) {
266 if path.is_file() {
267 return read_template(repo_root, &path).map(Some);
268 }
269 }
270
271 for dir in template_directories(repo_root) {
272 if let Some(template) = directory_template(repo_root, &dir)? {
273 return Ok(Some(template));
274 }
275 }
276
277 Ok(None)
278}
279
280fn singular_template_paths(repo_root: &Path) -> [PathBuf; 3] {
281 [
282 repo_root.join(".github/pull_request_template.md"),
283 repo_root.join("pull_request_template.md"),
284 repo_root.join("docs/pull_request_template.md"),
285 ]
286}
287
288fn template_directories(repo_root: &Path) -> [PathBuf; 3] {
289 [
290 repo_root.join(".github/PULL_REQUEST_TEMPLATE"),
291 repo_root.join("PULL_REQUEST_TEMPLATE"),
292 repo_root.join("docs/PULL_REQUEST_TEMPLATE"),
293 ]
294}
295
296fn directory_template(repo_root: &Path, dir: &Path) -> Result<Option<PullRequestTemplate>> {
297 if !dir.is_dir() {
298 return Ok(None);
299 }
300
301 let default_path = dir.join("pull_request_template.md");
302 if default_path.is_file() {
303 return read_template(repo_root, &default_path).map(Some);
304 }
305
306 let markdown_templates = markdown_files(dir)?;
307 if markdown_templates.len() == 1 {
308 read_template(repo_root, &markdown_templates[0]).map(Some)
309 } else {
310 Ok(None)
311 }
312}
313
314fn markdown_files(dir: &Path) -> Result<Vec<PathBuf>> {
315 let mut files = Vec::new();
316 for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? {
317 let path = entry?.path();
318 if path.is_file()
319 && path
320 .extension()
321 .and_then(|extension| extension.to_str())
322 .is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
323 {
324 files.push(path);
325 }
326 }
327 files.sort();
328 Ok(files)
329}
330
331fn read_template(repo_root: &Path, path: &Path) -> Result<PullRequestTemplate> {
332 let body = fs::read_to_string(path)
333 .with_context(|| format!("Failed to read PR template {}", path.display()))?;
334 let relative_path = path
335 .strip_prefix(repo_root)
336 .unwrap_or(path)
337 .to_string_lossy()
338 .to_string();
339
340 Ok(PullRequestTemplate {
341 path: relative_path,
342 body,
343 })
344}
345
346impl GitHubRepository {
347 pub fn parse(remote_url: &str) -> Result<Self> {
348 if let Some(path) = remote_url.strip_prefix("git@github.com:") {
349 return Self::parse_path(path);
350 }
351
352 let url = Url::parse(remote_url)
353 .with_context(|| format!("Could not parse GitHub remote URL `{remote_url}`"))?;
354 if url.host_str() != Some("github.com") {
355 bail!("Only github.com remotes are supported for GitHub publishing");
356 }
357 Self::parse_path(url.path().trim_start_matches('/'))
358 }
359
360 fn parse_path(path: &str) -> Result<Self> {
361 let clean_path = path.trim_end_matches(".git").trim_end_matches('/');
362 let mut parts = clean_path.split('/');
363 let owner = parts
364 .next()
365 .filter(|part| !part.is_empty())
366 .ok_or_else(|| anyhow!("GitHub remote URL is missing an owner"))?;
367 let name = parts
368 .next()
369 .filter(|part| !part.is_empty())
370 .ok_or_else(|| anyhow!("GitHub remote URL is missing a repository name"))?;
371
372 if parts.next().is_some() {
373 bail!("GitHub remote URL has an unexpected path shape");
374 }
375
376 Ok(Self {
377 owner: owner.to_string(),
378 name: name.to_string(),
379 })
380 }
381}
382
383fn github_remote_url(repo: &GitRepo) -> Result<String> {
384 if let Some(url) = repo.get_remote_url() {
385 return Ok(url.to_string());
386 }
387
388 let raw_repo = repo.open_repo()?;
389 let remote = raw_repo
390 .find_remote("origin")
391 .or_else(|_| {
392 let remotes = raw_repo.remotes()?;
393 let remote_name = remotes
394 .iter()
395 .flatten()
396 .next()
397 .ok_or(git2::Error::from_str("No git remotes configured"))?;
398 raw_repo.find_remote(remote_name)
399 })
400 .context("Could not find a git remote for GitHub publishing")?;
401
402 remote
403 .url()
404 .map(std::string::ToString::to_string)
405 .ok_or_else(|| anyhow!("Git remote has no URL"))
406}
407
408fn single_pull_number(pulls: &[PullRequest]) -> Option<u64> {
409 match pulls {
410 [pull] => Some(pull.number),
411 _ => None,
412 }
413}
414
415#[derive(Debug, Clone, PartialEq, Eq)]
416struct InlineCommentCandidate {
417 path: String,
418 start_line: Option<u64>,
419 line: u64,
420 body: String,
421}
422
423impl InlineCommentCandidate {
424 fn is_reviewable(&self, reviewable_lines: &HashMap<String, HashSet<u64>>) -> bool {
425 reviewable_lines.get(&self.path).is_some_and(|lines| {
426 let start = self.start_line.unwrap_or(self.line).min(self.line);
427 let end = self.start_line.unwrap_or(self.line).max(self.line);
428 (start..=end).all(|line| lines.contains(&line))
429 })
430 }
431}
432
433fn extract_inline_comment_candidates(review: &str) -> Vec<InlineCommentCandidate> {
434 let lines: Vec<&str> = review.lines().collect();
435 let mut candidates = Vec::new();
436 let mut index = 0;
437
438 while index < lines.len() {
439 let line = lines[index];
440 if let Some((path, line_number)) = extract_location(line)
441 && looks_like_finding(line)
442 {
443 let body = finding_body(&lines, index);
444 candidates.push(InlineCommentCandidate {
445 path,
446 start_line: None,
447 line: line_number,
448 body,
449 });
450 }
451 index += 1;
452 }
453
454 candidates
455}
456
457fn extract_structured_inline_comment_candidates(
458 review: &CodeReview,
459) -> Vec<InlineCommentCandidate> {
460 review
461 .visible_findings()
462 .into_iter()
463 .map(inline_comment_candidate_from_finding)
464 .collect()
465}
466
467fn inline_comment_candidate_from_finding(finding: &Finding) -> InlineCommentCandidate {
468 let start = finding.start_line.min(finding.end_line);
469 let end = finding.start_line.max(finding.end_line);
470 let start_line = (start != end).then_some(u64::from(start));
471
472 InlineCommentCandidate {
473 path: normalize_github_path(&finding.file),
474 start_line,
475 line: u64::from(end),
476 body: finding.raw_inline_body(),
477 }
478}
479
480fn review_body_with_permalinks(repo: &GitHubRepository, review: &CodeReview, sha: &str) -> String {
481 let mut body = review.raw_content();
482 let findings = review.visible_findings();
483 if findings.is_empty() {
484 return body;
485 }
486
487 body.push_str("\n## GitHub Permalinks\n");
488 for finding in findings {
489 body.push_str(&format!(
490 "\n- {}: {}\n",
491 finding.id.0,
492 permalink_for_finding(repo, finding, sha)
493 ));
494 }
495
496 body
497}
498
499fn permalink_for_finding(repo: &GitHubRepository, finding: &Finding, sha: &str) -> String {
500 let path = percent_encode_github_path(&normalize_github_path(&finding.file));
501 let start = finding.start_line.min(finding.end_line);
502 let end = finding.start_line.max(finding.end_line);
503 let line = if start == end {
504 format!("L{start}")
505 } else {
506 format!("L{start}-L{end}")
507 };
508
509 format!(
510 "https://github.com/{}/{}/blob/{}/{}#{}",
511 repo.owner, repo.name, sha, path, line
512 )
513}
514
515fn normalize_github_path(path: &Path) -> String {
516 normalize_github_path_str(&path.to_string_lossy())
517}
518
519fn normalize_github_path_str(path: &str) -> String {
520 path.replace('\\', "/")
521 .trim_start_matches("./")
522 .trim_start_matches('/')
523 .to_string()
524}
525
526fn percent_encode_github_path(path: &str) -> String {
527 path.split('/')
528 .map(percent_encode_path_segment)
529 .collect::<Vec<_>>()
530 .join("/")
531}
532
533fn percent_encode_path_segment(segment: &str) -> String {
534 let mut encoded = String::new();
535 for byte in segment.bytes() {
536 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
537 encoded.push(char::from(byte));
538 } else {
539 encoded.push_str(&format!("%{byte:02X}"));
540 }
541 }
542 encoded
543}
544
545fn extract_location(line: &str) -> Option<(String, u64)> {
546 BACKTICK_LOCATION_RE
547 .captures(line)
548 .or_else(|| PLAIN_LOCATION_RE.captures(line))
549 .and_then(|captures| {
550 let path = normalize_github_path_str(captures.get(1)?.as_str());
551 let line = captures.get(2)?.as_str().parse().ok()?;
552 Some((path, line))
553 })
554}
555
556fn looks_like_finding(line: &str) -> bool {
557 ["[CRITICAL]", "[HIGH]", "[MEDIUM]", "[LOW]"]
558 .iter()
559 .any(|severity| line.contains(severity))
560}
561
562fn finding_body(lines: &[&str], start: usize) -> String {
563 let mut body = Vec::new();
564 let mut index = start;
565
566 while index < lines.len() {
567 let line = lines[index];
568 if index > start && starts_new_finding_or_section(line) {
569 break;
570 }
571 body.push(line.trim());
572 index += 1;
573 }
574
575 body.join("\n").trim().to_string()
576}
577
578fn starts_new_finding_or_section(line: &str) -> bool {
579 let trimmed = line.trim_start();
580 trimmed.starts_with("# ") || trimmed.starts_with("## ") || looks_like_finding(trimmed)
581}
582
583fn parse_reviewable_lines(diff: &str) -> HashMap<String, HashSet<u64>> {
584 let mut lines_by_path = HashMap::new();
585 let mut current_path: Option<String> = None;
586 let mut new_line: Option<u64> = None;
587
588 for line in diff.lines() {
589 if let Some(path) = line.strip_prefix("+++ b/") {
590 current_path = Some(path.to_string());
591 continue;
592 }
593
594 if let Some(captures) = HUNK_RE.captures(line) {
595 new_line = captures.get(1).and_then(|m| m.as_str().parse().ok());
596 continue;
597 }
598
599 let Some(path) = current_path.as_ref() else {
600 continue;
601 };
602 let Some(line_number) = new_line else {
603 continue;
604 };
605
606 if let Some(b'+' | b' ') = line.as_bytes().first().copied() {
607 lines_by_path
608 .entry(path.clone())
609 .or_insert_with(HashSet::new)
610 .insert(line_number);
611 new_line = Some(line_number + 1);
612 }
613 }
614
615 lines_by_path
616}
617
618#[cfg(test)]
619mod tests;