Files
steep/crates/steep-server/src/gitea.rs
T
qpismont f5f0d3999e
ci/woodpecker/push/tests Pipeline was successful
rebranding herald => steep
2026-09-29 18:36:01 +00:00

572 lines
16 KiB
Rust

use std::time::Duration;
use futures_util::stream::TryStreamExt;
use serde::Deserialize;
use serde_json::{Value, json};
use tokio::io::AsyncReadExt;
use tokio_util::io::StreamReader;
use tracing::{instrument, warn};
use crate::{
bot::{ReviewResult, ReviewSeverity, ReviewSide},
consts::MAX_DIFF_SIZE,
errors::AppError,
};
/// Page size requested when listing the files of a pull request.
const FILE_PAGE_SIZE: u64 = 50;
/// Maximum number of pages fetched for a pull request file list.
const MAX_FILE_PAGES: u64 = 10;
#[derive(Clone)]
pub struct GiteaAPI {
base_url: String,
token: String,
client: reqwest::Client,
}
impl GiteaAPI {
pub fn new(base_url: &str, token: &str, timeout: u64) -> anyhow::Result<Self> {
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert(
reqwest::header::HeaderName::from_static("authorization"),
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token))?,
);
Ok(Self {
base_url: String::from(base_url),
token: String::from(token),
client: reqwest::Client::builder()
.timeout(Duration::from_secs(timeout))
.default_headers(default_headers)
.build()?,
})
}
/// API token used to authenticate against Gitea.
pub fn token(&self) -> &str {
&self.token
}
#[instrument(skip(self))]
pub async fn get_authorized_user(&self) -> anyhow::Result<User> {
let url = format!("{}/api/v1/user", self.base_url);
let res = self.client.get(url).send().await?;
if !res.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to get authorized user: {}",
res.status()
));
}
res.json::<User>().await.map_err(anyhow::Error::from)
}
#[instrument(skip(self))]
pub async fn comment(
&self,
body: &str,
full_name: &str,
index: u64,
) -> anyhow::Result<Comment> {
let url = format!(
"{}/api/v1/repos/{}/issues/{}/comments",
self.base_url, full_name, index
);
let res = self
.client
.post(url)
.json(&json!({
"body": body
}))
.send()
.await?;
if !res.status().is_success() {
return Err(anyhow::anyhow!("Failed to comment: {}", res.status()));
}
res.json::<Comment>().await.map_err(anyhow::Error::from)
}
#[instrument(skip(self))]
pub async fn edit_comment(
&self,
body: &str,
full_name: &str,
comment_id: u64,
) -> anyhow::Result<()> {
let url = format!(
"{}/api/v1/repos/{}/issues/comments/{}",
self.base_url, full_name, comment_id
);
let res = self
.client
.patch(url)
.json(&json!({
"body": body
}))
.send()
.await?;
if !res.status().is_success() {
return Err(anyhow::anyhow!("Failed to comment: {}", res.status()));
}
Ok(())
}
#[instrument(skip(self))]
pub async fn delete_comment(&self, full_name: &str, comment_id: u64) -> anyhow::Result<()> {
let url = format!(
"{}/api/v1/repos/{}/issues/comments/{}",
self.base_url, full_name, comment_id
);
let res = self.client.delete(url).send().await?;
if !res.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to delete comment: {}",
res.status()
));
}
Ok(())
}
/// Raw unified diff of a pull request.
///
/// The API endpoint is used rather than the `diff_url` carried by the
/// webhook: that one points at a web route, which is session authenticated
/// and therefore does not serve private repositories to an API token.
#[instrument(skip(self))]
pub async fn pull_request_diff(&self, full_name: &str, index: u64) -> anyhow::Result<String> {
let url = format!(
"{}/api/v1/repos/{}/pulls/{}.diff",
self.base_url, full_name, index
);
let res = self.client.get(url).send().await?;
if !res.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to download pull request diff: {}",
res.status()
));
}
read_capped(res).await
}
/// Files changed by a pull request, with their exact path and status.
#[instrument(skip(self))]
pub async fn pull_request_files(
&self,
full_name: &str,
index: u64,
) -> anyhow::Result<Vec<PullRequestFile>> {
let mut files = Vec::new();
for page in 1..=MAX_FILE_PAGES {
let url = format!(
"{}/api/v1/repos/{}/pulls/{}/files?limit={FILE_PAGE_SIZE}&page={page}",
self.base_url, full_name, index
);
let res = self.client.get(url).send().await?;
if !res.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to list pull request files: {}",
res.status()
));
}
let page_files = res.json::<Vec<PullRequestFile>>().await?;
// The instance may cap the page size below the requested one, so a
// short page is not the end of the list: an empty one is.
if page_files.is_empty() {
return Ok(files);
}
files.extend(page_files);
}
warn!(files = files.len(), "Pull request file list was truncated");
Ok(files)
}
#[instrument(skip(self, review_result))]
pub async fn post_pull_request_review(
&self,
review_result: &ReviewResult,
final_comment: &str,
full_name: &str,
index: u64,
) -> anyhow::Result<()> {
let url = format!(
"{}/api/v1/repos/{}/pulls/{}/reviews",
self.base_url, full_name, index
);
let comments = review_result
.reviews
.iter()
.filter_map(|review| {
let line = review.line?;
let path = review.filename.clone();
let severity = review
.severity
.unwrap_or(ReviewSeverity::Maintainability)
.label();
let body = format!("**[{severity}]** {}", review.message);
// A line removed by the pull request only exists in the old
// version of the file, so it is anchored with `old_position`.
Some(match review.side {
Some(ReviewSide::Removed) => json!({
"path": path,
"old_position": line,
"body": body
}),
_ => json!({
"path": path,
"new_position": line,
"body": body
}),
})
})
.collect::<Vec<_>>();
let res = self
.client
.post(url)
.json(&json!({
"event": "COMMENT",
"body": final_comment,
"comments": comments
}))
.send()
.await?;
if !res.status().is_success() {
return Err(anyhow::anyhow!("Failed to post review: {}", res.status()));
}
Ok(())
}
}
/// Reads a response body, refusing to buffer more than [`MAX_DIFF_SIZE`].
async fn read_capped(response: reqwest::Response) -> anyhow::Result<String> {
let stream = response.bytes_stream().map_err(std::io::Error::other);
let mut buf = Vec::with_capacity(MAX_DIFF_SIZE);
StreamReader::new(stream)
.take((MAX_DIFF_SIZE + 1) as u64)
.read_to_end(&mut buf)
.await?;
if buf.len() > MAX_DIFF_SIZE {
anyhow::bail!("Pull request diff exceeds the maximum allowed size of 1 MiB");
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
#[derive(Debug)]
pub enum WebhookType {
Review(ReviewPayload),
}
impl WebhookType {
pub fn event_id(&self) -> u64 {
match self {
WebhookType::Review(payload) => payload.comment.id,
}
}
pub fn event_type_str(&self) -> &'static str {
match self {
WebhookType::Review(_) => "review",
}
}
}
#[derive(Deserialize, Debug)]
pub struct ReviewPayload {
pub action: String,
pub pull_request: PullRequest,
pub repository: Repository,
pub comment: Comment,
}
#[derive(Deserialize, Debug)]
pub struct PullRequest {
pub number: u64,
pub title: String,
}
#[derive(Deserialize, Debug)]
pub struct Comment {
pub id: u64,
pub body: String,
}
#[derive(Deserialize, Debug)]
pub struct User {
pub login: String,
}
#[derive(Deserialize, Debug)]
pub struct Repository {
pub full_name: String,
pub clone_url: String,
}
/// A file changed by a pull request, as reported by the API.
#[derive(Deserialize, Debug)]
pub struct PullRequestFile {
/// Path of the file in the new version of the repository.
pub filename: String,
/// Previous path, for a renamed file.
#[serde(default)]
pub previous_filename: Option<String>,
/// `added`, `modified`, `deleted`, `renamed`…
pub status: String,
}
impl WebhookType {
pub fn from_event(event: &str, bot_name: &str, json: Value) -> Result<Self, AppError> {
let wb = match event {
"pull_request_comment" => Ok(WebhookType::Review(serde_json::from_value(json)?)),
_ => Err(AppError::UnknownEventErr),
}?;
let action = match &wb {
WebhookType::Review(review_payload) => &review_payload.action,
};
if action != "created" {
return Err(AppError::InvalidActionErr);
}
let pr_body = match &wb {
WebhookType::Review(review_payload) => &review_payload.comment.body,
};
if !pr_body.starts_with(&format!("@{}", bot_name)) {
return Err(AppError::UnauthorizedUserErr);
}
Ok(wb)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_from_event_valid_pull_request_comment() {
let json = json!({
"action": "created",
"pull_request": {
"id": 42,
"diff_url": "https://mydiff.fr",
"number": 1,
"title": "My PR"
},
"repository": {
"full_name": "owner/repo",
"clone_url": "https://github.com/owner/repo.git"
},
"comment": {
"id": 7,
"body": "@test_bot LGTM",
"user": {
"id": 100,
"login": "test_user"
}
}
});
let result = WebhookType::from_event("pull_request_comment", "test_bot", json);
assert!(result.is_ok());
match result.unwrap() {
WebhookType::Review(payload) => {
assert_eq!(payload.action, "created");
assert_eq!(payload.comment.id, 7);
assert_eq!(payload.comment.body, "@test_bot LGTM");
}
}
}
#[test]
fn test_from_event_unknown_event() {
let json = json!({});
let result = WebhookType::from_event("push", "test_bot", json);
assert!(result.is_err());
match result.unwrap_err() {
AppError::UnknownEventErr => {}
_ => panic!("expected UnknownEventErr"),
}
}
#[test]
fn test_from_event_malformed_json() {
let json = json!({
"action": "created"
// pull_request and comment are missing
});
let result = WebhookType::from_event("pull_request_comment", "test_bot", json);
assert!(result.is_err());
match result.unwrap_err() {
AppError::BadJsonStructErr(_) => {}
_ => panic!("expected BadJsonStructErr"),
}
}
#[test]
fn test_from_event_rejects_non_created_action() {
let json = json!({
"action": "edited",
"pull_request": {
"id": 1,
"diff_url": "https://mydiff.fr",
"number": 1,
"title": "My PR"
},
"repository": {
"full_name": "owner/repo",
"clone_url": "https://github.com/owner/repo.git"
},
"comment": {
"id": 1,
"body": "@test_bot body",
"user": {
"id": 1,
"login": "test_user"
}
}
});
let result = WebhookType::from_event("pull_request_comment", "test_bot", json);
assert!(result.is_err());
match result.unwrap_err() {
AppError::InvalidActionErr => {}
_ => panic!("expected InvalidActionErr"),
}
}
#[test]
fn test_deserialize_review_payload() {
let json = json!({
"action": "created",
"pull_request": {
"id": 99,
"diff_url": "https://mydiff.fr",
"number": 5,
"title": "My PR"
},
"repository": {
"full_name": "owner/repo",
"clone_url": "https://github.com/owner/repo.git"
},
"comment": {
"id": 12,
"body": "Needs work",
"user": {
"id": 200,
"login": "test_user"
}
}
});
let payload: ReviewPayload = serde_json::from_value(json).unwrap();
assert_eq!(payload.action, "created");
assert_eq!(payload.comment.id, 12);
assert_eq!(payload.comment.body, "Needs work");
assert_eq!(
payload.repository.clone_url,
"https://github.com/owner/repo.git"
);
}
#[test]
fn test_from_event_empty_json() {
let result = WebhookType::from_event("pull_request_comment", "test_bot", json!({}));
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AppError::BadJsonStructErr(_)));
}
#[test]
fn test_from_event_rejects_wrong_bot_name() {
let json = json!({
"action": "created",
"pull_request": {
"id": 1,
"diff_url": "https://mydiff.fr",
"number": 1,
"title": "My PR"
},
"repository": {
"full_name": "owner/repo",
"clone_url": "https://github.com/owner/repo.git"
},
"comment": {
"id": 1,
"body": "@other_bot do something",
"user": {
"id": 1,
"login": "test_user"
}
}
});
let result = WebhookType::from_event("pull_request_comment", "test_bot", json);
assert!(matches!(result.unwrap_err(), AppError::UnauthorizedUserErr));
}
#[test]
fn test_from_event_rejects_no_bot_prefix() {
let json = json!({
"action": "created",
"pull_request": {
"id": 1,
"diff_url": "https://mydiff.fr",
"number": 1,
"title": "My PR"
},
"repository": {
"full_name": "owner/repo",
"clone_url": "https://github.com/owner/repo.git"
},
"comment": {
"id": 1,
"body": "just a comment without bot mention",
"user": {
"id": 1,
"login": "test_user"
}
}
});
let result = WebhookType::from_event("pull_request_comment", "test_bot", json);
assert!(matches!(result.unwrap_err(), AppError::UnauthorizedUserErr));
}
}