Starting impl devcontainer spec
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{bot::ReviewResult, errors::AppError};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GiteaAPI {
|
||||
base_url: 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),
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.default_headers(default_headers)
|
||||
.build()?,
|
||||
})
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
||||
#[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(|r| r.line.is_some())
|
||||
.map(|r| {
|
||||
let path = r.filename.clone();
|
||||
let line = r.line.unwrap_or(0);
|
||||
let body = r.message.clone();
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[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 id: u64,
|
||||
pub diff_url: String,
|
||||
pub number: u64,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Comment {
|
||||
pub id: u64,
|
||||
pub body: String,
|
||||
pub user: User,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct User {
|
||||
pub id: u64,
|
||||
pub login: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Repository {
|
||||
pub full_name: 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"
|
||||
},
|
||||
"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.pull_request.id, 42);
|
||||
assert_eq!(payload.comment.id, 7);
|
||||
assert_eq!(payload.comment.body, "@test_bot LGTM");
|
||||
assert_eq!(payload.comment.user.id, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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.pull_request.id, 99);
|
||||
assert_eq!(payload.comment.id, 12);
|
||||
assert_eq!(payload.comment.body, "Needs work");
|
||||
assert_eq!(payload.comment.user.id, 200);
|
||||
}
|
||||
|
||||
#[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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user