1use axum::{
100 extract::{Path, Query, State},
101 http::StatusCode,
102 response::IntoResponse,
103 routing::{get, post},
104 Json, Router,
105};
106use guts_collaboration::{
107 CollaborationError, Comment, CommentTarget, Issue, IssueState, Label, PullRequest,
108 PullRequestState, Review, ReviewState,
109};
110use guts_storage::ObjectId;
111use serde::{Deserialize, Serialize};
112
113use crate::api::AppState;
114
115pub fn collaboration_routes() -> Router<AppState> {
117 Router::new()
118 .route(
120 "/api/repos/{owner}/{name}/pulls",
121 get(list_prs).post(create_pr),
122 )
123 .route(
124 "/api/repos/{owner}/{name}/pulls/{number}",
125 get(get_pr).patch(update_pr),
126 )
127 .route(
128 "/api/repos/{owner}/{name}/pulls/{number}/merge",
129 post(merge_pr),
130 )
131 .route(
132 "/api/repos/{owner}/{name}/pulls/{number}/comments",
133 get(list_pr_comments).post(create_pr_comment),
134 )
135 .route(
136 "/api/repos/{owner}/{name}/pulls/{number}/reviews",
137 get(list_reviews).post(create_review),
138 )
139 .route(
141 "/api/repos/{owner}/{name}/issues",
142 get(list_issues).post(create_issue),
143 )
144 .route(
145 "/api/repos/{owner}/{name}/issues/{number}",
146 get(get_issue).patch(update_issue),
147 )
148 .route(
149 "/api/repos/{owner}/{name}/issues/{number}/comments",
150 get(list_issue_comments).post(create_issue_comment),
151 )
152}
153
154#[derive(Debug, Deserialize)]
158pub struct ListPRsQuery {
159 pub state: Option<String>,
160}
161
162#[derive(Debug, Deserialize)]
164pub struct ListIssuesQuery {
165 pub state: Option<String>,
166}
167
168#[derive(Debug, Deserialize)]
170pub struct CreatePRRequest {
171 pub title: String,
172 pub description: String,
173 pub author: String,
174 pub source_branch: String,
175 pub target_branch: String,
176 pub source_commit: String,
177 pub target_commit: String,
178}
179
180#[derive(Debug, Deserialize)]
182pub struct UpdatePRRequest {
183 pub title: Option<String>,
184 pub description: Option<String>,
185 pub state: Option<String>,
186}
187
188#[derive(Debug, Deserialize)]
190pub struct MergePRRequest {
191 pub merged_by: String,
192}
193
194#[derive(Debug, Deserialize)]
196pub struct CreateIssueRequest {
197 pub title: String,
198 pub description: String,
199 pub author: String,
200 pub labels: Option<Vec<String>>,
201}
202
203#[derive(Debug, Deserialize)]
205pub struct UpdateIssueRequest {
206 pub title: Option<String>,
207 pub description: Option<String>,
208 pub state: Option<String>,
209 pub closed_by: Option<String>,
210}
211
212#[derive(Debug, Deserialize)]
214pub struct CreateCommentRequest {
215 pub author: String,
216 pub body: String,
217}
218
219#[derive(Debug, Deserialize)]
221pub struct CreateReviewRequest {
222 pub author: String,
223 pub state: String,
224 pub body: Option<String>,
225 pub commit_id: String,
226}
227
228#[derive(Debug, Serialize)]
230pub struct PullRequestResponse {
231 pub id: u64,
232 pub number: u32,
233 pub title: String,
234 pub description: String,
235 pub author: String,
236 pub state: String,
237 pub source_branch: String,
238 pub target_branch: String,
239 pub source_commit: String,
240 pub target_commit: String,
241 pub labels: Vec<LabelResponse>,
242 pub created_at: u64,
243 pub updated_at: u64,
244 pub merged_at: Option<u64>,
245 pub merged_by: Option<String>,
246}
247
248impl From<PullRequest> for PullRequestResponse {
249 fn from(pr: PullRequest) -> Self {
250 Self {
251 id: pr.id,
252 number: pr.number,
253 title: pr.title,
254 description: pr.description,
255 author: pr.author,
256 state: pr.state.to_string(),
257 source_branch: pr.source_branch,
258 target_branch: pr.target_branch,
259 source_commit: pr.source_commit.to_hex(),
260 target_commit: pr.target_commit.to_hex(),
261 labels: pr.labels.into_iter().map(Into::into).collect(),
262 created_at: pr.created_at,
263 updated_at: pr.updated_at,
264 merged_at: pr.merged_at,
265 merged_by: pr.merged_by,
266 }
267 }
268}
269
270#[derive(Debug, Serialize)]
272pub struct IssueResponse {
273 pub id: u64,
274 pub number: u32,
275 pub title: String,
276 pub description: String,
277 pub author: String,
278 pub state: String,
279 pub labels: Vec<LabelResponse>,
280 pub created_at: u64,
281 pub updated_at: u64,
282 pub closed_at: Option<u64>,
283 pub closed_by: Option<String>,
284}
285
286impl From<Issue> for IssueResponse {
287 fn from(issue: Issue) -> Self {
288 Self {
289 id: issue.id,
290 number: issue.number,
291 title: issue.title,
292 description: issue.description,
293 author: issue.author,
294 state: issue.state.to_string(),
295 labels: issue.labels.into_iter().map(Into::into).collect(),
296 created_at: issue.created_at,
297 updated_at: issue.updated_at,
298 closed_at: issue.closed_at,
299 closed_by: issue.closed_by,
300 }
301 }
302}
303
304#[derive(Debug, Serialize)]
306pub struct LabelResponse {
307 pub name: String,
308 pub color: String,
309 pub description: Option<String>,
310}
311
312impl From<Label> for LabelResponse {
313 fn from(label: Label) -> Self {
314 Self {
315 name: label.name,
316 color: label.color,
317 description: label.description,
318 }
319 }
320}
321
322#[derive(Debug, Serialize)]
324pub struct CommentResponse {
325 pub id: u64,
326 pub author: String,
327 pub body: String,
328 pub created_at: u64,
329 pub updated_at: u64,
330}
331
332impl From<Comment> for CommentResponse {
333 fn from(comment: Comment) -> Self {
334 Self {
335 id: comment.id,
336 author: comment.author,
337 body: comment.body,
338 created_at: comment.created_at,
339 updated_at: comment.updated_at,
340 }
341 }
342}
343
344#[derive(Debug, Serialize)]
346pub struct ReviewResponse {
347 pub id: u64,
348 pub pr_number: u32,
349 pub author: String,
350 pub state: String,
351 pub body: Option<String>,
352 pub commit_id: String,
353 pub created_at: u64,
354}
355
356impl From<Review> for ReviewResponse {
357 fn from(review: Review) -> Self {
358 Self {
359 id: review.id,
360 pr_number: review.pr_number,
361 author: review.author,
362 state: review.state.to_string(),
363 body: review.body,
364 commit_id: review.commit_id,
365 created_at: review.created_at,
366 }
367 }
368}
369
370#[derive(Debug, Serialize)]
372struct ErrorResponse {
373 error: String,
374}
375
376impl IntoResponse for CollaborationApiError {
378 fn into_response(self) -> axum::response::Response {
379 let (status, message) = match &self.0 {
380 CollaborationError::PullRequestNotFound { .. } => {
381 (StatusCode::NOT_FOUND, self.0.to_string())
382 }
383 CollaborationError::IssueNotFound { .. } => (StatusCode::NOT_FOUND, self.0.to_string()),
384 CollaborationError::CommentNotFound { .. } => {
385 (StatusCode::NOT_FOUND, self.0.to_string())
386 }
387 CollaborationError::ReviewNotFound { .. } => {
388 (StatusCode::NOT_FOUND, self.0.to_string())
389 }
390 CollaborationError::PullRequestExists { .. } => {
391 (StatusCode::CONFLICT, self.0.to_string())
392 }
393 CollaborationError::IssueExists { .. } => (StatusCode::CONFLICT, self.0.to_string()),
394 CollaborationError::InvalidStateTransition { .. } => {
395 (StatusCode::BAD_REQUEST, self.0.to_string())
396 }
397 CollaborationError::AlreadyMerged { .. } => {
398 (StatusCode::BAD_REQUEST, self.0.to_string())
399 }
400 CollaborationError::PullRequestClosed { .. } => {
401 (StatusCode::BAD_REQUEST, self.0.to_string())
402 }
403 CollaborationError::IssueClosed { .. } => (StatusCode::BAD_REQUEST, self.0.to_string()),
404 CollaborationError::RepoNotFound { .. } => (StatusCode::NOT_FOUND, self.0.to_string()),
405 CollaborationError::Validation(_) => (StatusCode::BAD_REQUEST, self.0.to_string()),
406 CollaborationError::Serialization(_) => {
407 (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string())
408 }
409 };
410
411 (status, Json(ErrorResponse { error: message })).into_response()
412 }
413}
414
415struct CollaborationApiError(CollaborationError);
417
418impl From<CollaborationError> for CollaborationApiError {
419 fn from(err: CollaborationError) -> Self {
420 Self(err)
421 }
422}
423
424async fn list_prs(
428 State(state): State<AppState>,
429 Path((owner, name)): Path<(String, String)>,
430 Query(params): Query<ListPRsQuery>,
431) -> Result<impl IntoResponse, CollaborationApiError> {
432 let repo_key = format!("{}/{}", owner, name);
433 let pr_state = params.state.as_deref().and_then(parse_pr_state);
434
435 let prs = state.collaboration.list_pull_requests(&repo_key, pr_state);
436 let responses: Vec<PullRequestResponse> = prs.into_iter().map(Into::into).collect();
437
438 Ok(Json(responses))
439}
440
441async fn create_pr(
443 State(state): State<AppState>,
444 Path((owner, name)): Path<(String, String)>,
445 Json(req): Json<CreatePRRequest>,
446) -> Result<impl IntoResponse, CollaborationApiError> {
447 let repo_key = format!("{}/{}", owner, name);
448
449 let source_commit = ObjectId::from_hex(&req.source_commit)
450 .map_err(|e| CollaborationError::Validation(e.to_string()))?;
451 let target_commit = ObjectId::from_hex(&req.target_commit)
452 .map_err(|e| CollaborationError::Validation(e.to_string()))?;
453
454 let pr = PullRequest::new(
455 0,
456 &repo_key,
457 0,
458 req.title,
459 req.description,
460 req.author,
461 req.source_branch,
462 req.target_branch,
463 source_commit,
464 target_commit,
465 );
466
467 let created = state.collaboration.create_pull_request(pr)?;
468
469 Ok((
470 StatusCode::CREATED,
471 Json(PullRequestResponse::from(created)),
472 ))
473}
474
475async fn get_pr(
477 State(state): State<AppState>,
478 Path((owner, name, number)): Path<(String, String, u32)>,
479) -> Result<impl IntoResponse, CollaborationApiError> {
480 let repo_key = format!("{}/{}", owner, name);
481 let pr = state.collaboration.get_pull_request(&repo_key, number)?;
482
483 Ok(Json(PullRequestResponse::from(pr)))
484}
485
486async fn update_pr(
488 State(state): State<AppState>,
489 Path((owner, name, number)): Path<(String, String, u32)>,
490 Json(req): Json<UpdatePRRequest>,
491) -> Result<impl IntoResponse, CollaborationApiError> {
492 let repo_key = format!("{}/{}", owner, name);
493
494 let updated = state
495 .collaboration
496 .update_pull_request(&repo_key, number, |pr| {
497 if let Some(title) = &req.title {
498 pr.update_title(title);
499 }
500 if let Some(desc) = &req.description {
501 pr.update_description(desc);
502 }
503 if let Some(state_str) = &req.state {
504 match state_str.as_str() {
505 "closed" => pr.close()?,
506 "open" => pr.reopen()?,
507 _ => {
508 return Err(CollaborationError::Validation(format!(
509 "invalid state: {}",
510 state_str
511 )))
512 }
513 }
514 }
515 Ok(())
516 })?;
517
518 Ok(Json(PullRequestResponse::from(updated)))
519}
520
521async fn merge_pr(
523 State(state): State<AppState>,
524 Path((owner, name, number)): Path<(String, String, u32)>,
525 Json(req): Json<MergePRRequest>,
526) -> Result<impl IntoResponse, CollaborationApiError> {
527 let repo_key = format!("{}/{}", owner, name);
528 let merged = state
529 .collaboration
530 .merge_pull_request(&repo_key, number, &req.merged_by)?;
531
532 Ok(Json(PullRequestResponse::from(merged)))
533}
534
535async fn list_pr_comments(
537 State(state): State<AppState>,
538 Path((owner, name, number)): Path<(String, String, u32)>,
539) -> Result<impl IntoResponse, CollaborationApiError> {
540 let repo_key = format!("{}/{}", owner, name);
541 let comments = state.collaboration.list_pr_comments(&repo_key, number);
542 let responses: Vec<CommentResponse> = comments.into_iter().map(Into::into).collect();
543
544 Ok(Json(responses))
545}
546
547async fn create_pr_comment(
549 State(state): State<AppState>,
550 Path((owner, name, number)): Path<(String, String, u32)>,
551 Json(req): Json<CreateCommentRequest>,
552) -> Result<impl IntoResponse, CollaborationApiError> {
553 let repo_key = format!("{}/{}", owner, name);
554 let target = CommentTarget::pull_request(&repo_key, number);
555 let comment = Comment::new(0, target, req.author, req.body);
556 let created = state.collaboration.create_comment(comment)?;
557
558 Ok((StatusCode::CREATED, Json(CommentResponse::from(created))))
559}
560
561async fn list_reviews(
563 State(state): State<AppState>,
564 Path((owner, name, number)): Path<(String, String, u32)>,
565) -> Result<impl IntoResponse, CollaborationApiError> {
566 let repo_key = format!("{}/{}", owner, name);
567 let reviews = state.collaboration.list_reviews(&repo_key, number);
568 let responses: Vec<ReviewResponse> = reviews.into_iter().map(Into::into).collect();
569
570 Ok(Json(responses))
571}
572
573async fn create_review(
575 State(state): State<AppState>,
576 Path((owner, name, number)): Path<(String, String, u32)>,
577 Json(req): Json<CreateReviewRequest>,
578) -> Result<impl IntoResponse, CollaborationApiError> {
579 let repo_key = format!("{}/{}", owner, name);
580 let review_state = parse_review_state(&req.state).ok_or_else(|| {
581 CollaborationError::Validation(format!("invalid review state: {}", req.state))
582 })?;
583
584 let mut review = Review::new(
585 0,
586 &repo_key,
587 number,
588 req.author,
589 review_state,
590 req.commit_id,
591 );
592 if let Some(body) = req.body {
593 review = review.with_body(body);
594 }
595
596 let created = state.collaboration.create_review(review)?;
597
598 Ok((StatusCode::CREATED, Json(ReviewResponse::from(created))))
599}
600
601async fn list_issues(
605 State(state): State<AppState>,
606 Path((owner, name)): Path<(String, String)>,
607 Query(params): Query<ListIssuesQuery>,
608) -> Result<impl IntoResponse, CollaborationApiError> {
609 let repo_key = format!("{}/{}", owner, name);
610 let issue_state = params.state.as_deref().and_then(parse_issue_state);
611
612 let issues = state.collaboration.list_issues(&repo_key, issue_state);
613 let responses: Vec<IssueResponse> = issues.into_iter().map(Into::into).collect();
614
615 Ok(Json(responses))
616}
617
618async fn create_issue(
620 State(state): State<AppState>,
621 Path((owner, name)): Path<(String, String)>,
622 Json(req): Json<CreateIssueRequest>,
623) -> Result<impl IntoResponse, CollaborationApiError> {
624 let repo_key = format!("{}/{}", owner, name);
625
626 let mut issue = Issue::new(0, &repo_key, 0, req.title, req.description, req.author);
627
628 if let Some(labels) = req.labels {
629 for label_name in labels {
630 issue.add_label(Label::new(label_name, "888888"));
631 }
632 }
633
634 let created = state.collaboration.create_issue(issue)?;
635
636 Ok((StatusCode::CREATED, Json(IssueResponse::from(created))))
637}
638
639async fn get_issue(
641 State(state): State<AppState>,
642 Path((owner, name, number)): Path<(String, String, u32)>,
643) -> Result<impl IntoResponse, CollaborationApiError> {
644 let repo_key = format!("{}/{}", owner, name);
645 let issue = state.collaboration.get_issue(&repo_key, number)?;
646
647 Ok(Json(IssueResponse::from(issue)))
648}
649
650async fn update_issue(
652 State(state): State<AppState>,
653 Path((owner, name, number)): Path<(String, String, u32)>,
654 Json(req): Json<UpdateIssueRequest>,
655) -> Result<impl IntoResponse, CollaborationApiError> {
656 let repo_key = format!("{}/{}", owner, name);
657
658 let updated = state
659 .collaboration
660 .update_issue(&repo_key, number, |issue| {
661 if let Some(title) = &req.title {
662 issue.update_title(title);
663 }
664 if let Some(desc) = &req.description {
665 issue.update_description(desc);
666 }
667 if let Some(state_str) = &req.state {
668 match state_str.as_str() {
669 "closed" => {
670 let closed_by = req.closed_by.as_deref().unwrap_or("unknown");
671 issue.close(closed_by)?;
672 }
673 "open" => issue.reopen()?,
674 _ => {
675 return Err(CollaborationError::Validation(format!(
676 "invalid state: {}",
677 state_str
678 )))
679 }
680 }
681 }
682 Ok(())
683 })?;
684
685 Ok(Json(IssueResponse::from(updated)))
686}
687
688async fn list_issue_comments(
690 State(state): State<AppState>,
691 Path((owner, name, number)): Path<(String, String, u32)>,
692) -> Result<impl IntoResponse, CollaborationApiError> {
693 let repo_key = format!("{}/{}", owner, name);
694 let comments = state.collaboration.list_issue_comments(&repo_key, number);
695 let responses: Vec<CommentResponse> = comments.into_iter().map(Into::into).collect();
696
697 Ok(Json(responses))
698}
699
700async fn create_issue_comment(
702 State(state): State<AppState>,
703 Path((owner, name, number)): Path<(String, String, u32)>,
704 Json(req): Json<CreateCommentRequest>,
705) -> Result<impl IntoResponse, CollaborationApiError> {
706 let repo_key = format!("{}/{}", owner, name);
707 let target = CommentTarget::issue(&repo_key, number);
708 let comment = Comment::new(0, target, req.author, req.body);
709 let created = state.collaboration.create_comment(comment)?;
710
711 Ok((StatusCode::CREATED, Json(CommentResponse::from(created))))
712}
713
714fn parse_pr_state(s: &str) -> Option<PullRequestState> {
717 match s.to_lowercase().as_str() {
718 "open" => Some(PullRequestState::Open),
719 "closed" => Some(PullRequestState::Closed),
720 "merged" => Some(PullRequestState::Merged),
721 _ => None,
722 }
723}
724
725fn parse_issue_state(s: &str) -> Option<IssueState> {
726 match s.to_lowercase().as_str() {
727 "open" => Some(IssueState::Open),
728 "closed" => Some(IssueState::Closed),
729 _ => None,
730 }
731}
732
733fn parse_review_state(s: &str) -> Option<ReviewState> {
734 match s.to_lowercase().as_str() {
735 "approved" => Some(ReviewState::Approved),
736 "changes_requested" => Some(ReviewState::ChangesRequested),
737 "commented" => Some(ReviewState::Commented),
738 _ => None,
739 }
740}