guts_node/
collaboration_api.rs

1//! # Collaboration API
2//!
3//! This module provides HTTP endpoints for code collaboration features:
4//!
5//! - **Pull Requests**: Merge proposals with code review workflow
6//! - **Issues**: Bug reports, feature requests, and task tracking
7//! - **Comments**: Threaded discussions on PRs and Issues
8//! - **Reviews**: Code reviews with approval/rejection states
9//!
10//! ## Pull Request Endpoints
11//!
12//! | Method | Path | Description |
13//! |--------|------|-------------|
14//! | GET | `/api/repos/{owner}/{name}/pulls` | List pull requests |
15//! | POST | `/api/repos/{owner}/{name}/pulls` | Create a pull request |
16//! | GET | `/api/repos/{owner}/{name}/pulls/{number}` | Get PR details |
17//! | PATCH | `/api/repos/{owner}/{name}/pulls/{number}` | Update PR (title, state) |
18//! | POST | `/api/repos/{owner}/{name}/pulls/{number}/merge` | Merge the PR |
19//! | GET | `/api/repos/{owner}/{name}/pulls/{number}/comments` | List PR comments |
20//! | POST | `/api/repos/{owner}/{name}/pulls/{number}/comments` | Add a comment |
21//! | GET | `/api/repos/{owner}/{name}/pulls/{number}/reviews` | List reviews |
22//! | POST | `/api/repos/{owner}/{name}/pulls/{number}/reviews` | Submit a review |
23//!
24//! ## Issue Endpoints
25//!
26//! | Method | Path | Description |
27//! |--------|------|-------------|
28//! | GET | `/api/repos/{owner}/{name}/issues` | List issues |
29//! | POST | `/api/repos/{owner}/{name}/issues` | Create an issue |
30//! | GET | `/api/repos/{owner}/{name}/issues/{number}` | Get issue details |
31//! | PATCH | `/api/repos/{owner}/{name}/issues/{number}` | Update issue |
32//! | GET | `/api/repos/{owner}/{name}/issues/{number}/comments` | List comments |
33//! | POST | `/api/repos/{owner}/{name}/issues/{number}/comments` | Add a comment |
34//!
35//! ## State Transitions
36//!
37//! ### Pull Request States
38//!
39//! ```text
40//! Open ──┬──> Closed ──> Open (reopen)
41//!        └──> Merged (terminal)
42//! ```
43//!
44//! ### Issue States
45//!
46//! ```text
47//! Open <──> Closed
48//! ```
49//!
50//! ### Review States
51//!
52//! - `Pending`: Review in progress
53//! - `Commented`: Feedback without explicit approval
54//! - `Approved`: Code approved
55//! - `ChangesRequested`: Changes needed before merge
56//! - `Dismissed`: Review dismissed by maintainer
57//!
58//! ## Query Parameters
59//!
60//! List endpoints support filtering:
61//!
62//! ```bash
63//! # List open PRs only
64//! GET /api/repos/alice/myrepo/pulls?state=open
65//!
66//! # List closed issues
67//! GET /api/repos/alice/myrepo/issues?state=closed
68//! ```
69//!
70//! ## Example: Creating a Pull Request
71//!
72//! ```bash
73//! curl -X POST http://localhost:8080/api/repos/alice/myrepo/pulls \
74//!   -H "Content-Type: application/json" \
75//!   -d '{
76//!     "title": "Add new feature",
77//!     "description": "Implements the requested feature",
78//!     "author": "bob",
79//!     "source_branch": "feature/new-feature",
80//!     "target_branch": "main",
81//!     "source_commit": "abc123",
82//!     "target_commit": "def456"
83//!   }'
84//! ```
85//!
86//! ## Example: Submitting a Review
87//!
88//! ```bash
89//! curl -X POST http://localhost:8080/api/repos/alice/myrepo/pulls/1/reviews \
90//!   -H "Content-Type: application/json" \
91//!   -d '{
92//!     "author": "alice",
93//!     "state": "approved",
94//!     "body": "LGTM!",
95//!     "commit_id": "abc123"
96//!   }'
97//! ```
98
99use 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
115/// Creates the collaboration API routes.
116pub fn collaboration_routes() -> Router<AppState> {
117    Router::new()
118        // Pull Request endpoints
119        .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        // Issue endpoints
140        .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// ==================== Request/Response Types ====================
155
156/// Query parameters for listing pull requests.
157#[derive(Debug, Deserialize)]
158pub struct ListPRsQuery {
159    pub state: Option<String>,
160}
161
162/// Query parameters for listing issues.
163#[derive(Debug, Deserialize)]
164pub struct ListIssuesQuery {
165    pub state: Option<String>,
166}
167
168/// Request to create a pull request.
169#[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/// Request to update a pull request.
181#[derive(Debug, Deserialize)]
182pub struct UpdatePRRequest {
183    pub title: Option<String>,
184    pub description: Option<String>,
185    pub state: Option<String>,
186}
187
188/// Request to merge a pull request.
189#[derive(Debug, Deserialize)]
190pub struct MergePRRequest {
191    pub merged_by: String,
192}
193
194/// Request to create an issue.
195#[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/// Request to update an issue.
204#[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/// Request to create a comment.
213#[derive(Debug, Deserialize)]
214pub struct CreateCommentRequest {
215    pub author: String,
216    pub body: String,
217}
218
219/// Request to create a review.
220#[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/// Response for a pull request.
229#[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/// Response for an issue.
271#[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/// Response for a label.
305#[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/// Response for a comment.
323#[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/// Response for a review.
345#[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/// Error response.
371#[derive(Debug, Serialize)]
372struct ErrorResponse {
373    error: String,
374}
375
376/// Convert collaboration errors to HTTP responses.
377impl 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
415/// Wrapper for collaboration errors.
416struct CollaborationApiError(CollaborationError);
417
418impl From<CollaborationError> for CollaborationApiError {
419    fn from(err: CollaborationError) -> Self {
420        Self(err)
421    }
422}
423
424// ==================== Pull Request Handlers ====================
425
426/// Lists pull requests for a repository.
427async 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
441/// Creates a new pull request.
442async 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
475/// Gets a specific pull request.
476async 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
486/// Updates a pull request.
487async 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
521/// Merges a pull request.
522async 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
535/// Lists comments on a pull request.
536async 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
547/// Creates a comment on a pull request.
548async 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
561/// Lists reviews on a pull request.
562async 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
573/// Creates a review on a pull request.
574async 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
601// ==================== Issue Handlers ====================
602
603/// Lists issues for a repository.
604async 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
618/// Creates a new issue.
619async 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
639/// Gets a specific issue.
640async 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
650/// Updates an issue.
651async 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
688/// Lists comments on an issue.
689async 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
700/// Creates a comment on an issue.
701async 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
714// ==================== Helper Functions ====================
715
716fn 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}