use anyhow::Result;
use reqwest::Client;
use crate::config::Config;
mod bitbucket;
mod github;
mod gitlab;
mod types;
pub use types::{InlineComment, PrMeta, ProviderName, ReviewPost};
pub fn is_bot_comment(cfg: &crate::config::Config, body: &str) -> bool {
body.contains(&cfg.comment_marker)
}
pub(crate) fn finding_fingerprint(path: &str, body: &str) -> String {
use sha2::{Digest, Sha256};
fn norm(s: &str) -> String {
s.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
let mut h = Sha256::new();
h.update(norm(path).as_bytes());
h.update(b"|");
h.update(norm(body).as_bytes());
hex::encode(&h.finalize()[..6]) }
pub(crate) fn fp_marker(fp: &str) -> String {
format!("<!-- prbot-fp:{fp} -->")
}
pub(crate) fn extract_fp(body: &str) -> Option<String> {
const PREFIX: &str = "<!-- prbot-fp:";
let start = body.find(PREFIX)? + PREFIX.len();
let rest = &body[start..];
let end = rest.find("-->")?;
let fp = rest[..end].trim();
(!fp.is_empty()).then(|| fp.to_string())
}
pub(crate) fn render_resolved(resolved: &[String]) -> String {
if resolved.is_empty() {
return String::new();
}
let mut s = format!(
"\n\n## ✅ Resolved since last review\n\n_{} previously-flagged finding(s) no longer present:_\n",
resolved.len()
);
for r in resolved {
s.push_str(&format!("- {r}\n"));
}
s
}
#[derive(Clone, Copy)]
pub enum Provider {
Github,
Bitbucket,
Gitlab,
}
impl Provider {
pub fn from_name(name: &str) -> Result<Self> {
match name {
"github" => Ok(Provider::Github),
"bitbucket" => Ok(Provider::Bitbucket),
"gitlab" => Ok(Provider::Gitlab),
other => anyhow::bail!(
"Unknown provider \"{other}\" (expected: github | bitbucket | gitlab)"
),
}
}
pub fn name(&self) -> ProviderName {
match self {
Provider::Github => "github",
Provider::Bitbucket => "bitbucket",
Provider::Gitlab => "gitlab",
}
}
pub async fn get_diff(
&self,
client: &Client,
cfg: &Config,
repo: &str,
pr: u64,
) -> Result<String> {
match self {
Provider::Github => github::get_diff(client, cfg, repo, pr).await,
Provider::Bitbucket => bitbucket::get_diff(client, cfg, repo, pr).await,
Provider::Gitlab => gitlab::get_diff(client, cfg, repo, pr).await,
}
}
pub async fn get_meta(
&self,
client: &Client,
cfg: &Config,
repo: &str,
pr: u64,
) -> Result<PrMeta> {
match self {
Provider::Github => github::get_meta(client, cfg, repo, pr).await,
Provider::Bitbucket => bitbucket::get_meta(client, cfg, repo, pr).await,
Provider::Gitlab => gitlab::get_meta(client, cfg, repo, pr).await,
}
}
pub async fn get_file_contents(
&self,
client: &Client,
cfg: &Config,
repo: &str,
r#ref: &str,
path: &str,
) -> Result<Option<String>> {
match self {
Provider::Github => github::get_file_contents(client, cfg, repo, r#ref, path).await,
Provider::Bitbucket => {
bitbucket::get_file_contents(client, cfg, repo, r#ref, path).await
}
Provider::Gitlab => gitlab::get_file_contents(client, cfg, repo, r#ref, path).await,
}
}
pub fn clone_url(&self, cfg: &Config, repo: &str) -> Result<String> {
match self {
Provider::Github => github::clone_url(cfg, repo),
Provider::Bitbucket => bitbucket::clone_url(cfg, repo),
Provider::Gitlab => gitlab::clone_url(cfg, repo),
}
}
pub async fn post_comment(
&self,
client: &Client,
cfg: &Config,
repo: &str,
pr: u64,
body: &str,
) -> Result<Option<String>> {
match self {
Provider::Github => github::post_comment(client, cfg, repo, pr, body).await,
Provider::Bitbucket => bitbucket::post_comment(client, cfg, repo, pr, body).await,
Provider::Gitlab => gitlab::post_comment(client, cfg, repo, pr, body).await,
}
}
pub async fn update_pr_description(
&self,
client: &Client,
cfg: &Config,
meta: &PrMeta,
description: &str,
) -> Result<()> {
match self {
Provider::Github => {
github::update_pr_description(client, cfg, &meta.repo, meta.pr, description).await
}
Provider::Bitbucket => {
bitbucket::update_pr_description(client, cfg, meta, description).await
}
Provider::Gitlab => {
gitlab::update_pr_description(client, cfg, &meta.repo, meta.pr, description).await
}
}
}
pub async fn post_review(
&self,
client: &Client,
cfg: &Config,
meta: &PrMeta,
review: &ReviewPost,
) -> Result<Option<String>> {
match self {
Provider::Github => github::post_review(client, cfg, meta, review).await,
Provider::Bitbucket => bitbucket::post_review(client, cfg, meta, review).await,
Provider::Gitlab => gitlab::post_review(client, cfg, meta, review).await,
}
}
}
#[cfg(test)]
mod tests {
use super::{extract_fp, finding_fingerprint, fp_marker, render_resolved};
#[test]
fn fingerprint_is_stable_and_normalizes() {
let a = finding_fingerprint("src/lib.rs", "Missing null check. Fix: guard it.");
let b = finding_fingerprint("src/lib.rs", "missing null check. fix: guard it.");
assert_eq!(a, b);
assert_ne!(
a,
finding_fingerprint("src/other.rs", "Missing null check. Fix: guard it.")
);
assert_ne!(
a,
finding_fingerprint("src/lib.rs", "A totally different problem.")
);
assert_eq!(a.len(), 12); }
#[test]
fn fp_marker_round_trips() {
let fp = finding_fingerprint("a.rs", "some finding");
let body = format!("⚠️ **HIGH** — some finding\n\n_bot_\n{}", fp_marker(&fp));
assert_eq!(extract_fp(&body).as_deref(), Some(fp.as_str()));
}
#[test]
fn extract_fp_absent() {
assert_eq!(extract_fp("a plain comment with no marker"), None);
}
#[test]
fn render_resolved_empty_and_nonempty() {
assert_eq!(render_resolved(&[]), "");
let s = render_resolved(&["`src/a.rs`".to_string(), "`src/b.rs`".to_string()]);
assert!(s.contains("Resolved since last review"));
assert!(s.contains("`src/a.rs`") && s.contains("`src/b.rs`"));
assert!(s.contains("2 previously-flagged"));
}
}