Skip to main content

git_iris/
github.rs

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;