1use axum::{
92 extract::{Path, State},
93 http::StatusCode,
94 response::IntoResponse,
95 routing::{get, post, put},
96 Json, Router,
97};
98use guts_auth::{
99 AuthError, BranchProtection, BranchProtectionRequest, Collaborator, CreateWebhookRequest,
100 OrgMember, OrgRole, Organization, Permission, Team, UpdateWebhookRequest, Webhook,
101 WebhookEvent,
102};
103use serde::{Deserialize, Serialize};
104use std::collections::HashSet;
105
106use crate::api::AppState;
107
108pub fn auth_routes() -> Router<AppState> {
110 Router::new()
111 .route("/api/orgs", get(list_orgs).post(create_org))
113 .route(
114 "/api/orgs/{org}",
115 get(get_org).patch(update_org).delete(delete_org),
116 )
117 .route(
118 "/api/orgs/{org}/members",
119 get(list_org_members).post(add_org_member),
120 )
121 .route(
122 "/api/orgs/{org}/members/{user}",
123 put(update_org_member).delete(remove_org_member),
124 )
125 .route("/api/orgs/{org}/teams", get(list_teams).post(create_team))
127 .route(
128 "/api/orgs/{org}/teams/{team}",
129 get(get_team).patch(update_team).delete(delete_team),
130 )
131 .route(
132 "/api/orgs/{org}/teams/{team}/members",
133 get(list_team_members),
134 )
135 .route(
136 "/api/orgs/{org}/teams/{team}/members/{user}",
137 put(add_team_member).delete(remove_team_member),
138 )
139 .route("/api/orgs/{org}/teams/{team}/repos", get(list_team_repos))
140 .route(
141 "/api/orgs/{org}/teams/{team}/repos/{owner}/{name}",
142 put(add_team_repo).delete(remove_team_repo),
143 )
144 .route(
146 "/api/repos/{owner}/{name}/collaborators",
147 get(list_collaborators),
148 )
149 .route(
150 "/api/repos/{owner}/{name}/collaborators/{user}",
151 get(get_collaborator)
152 .put(add_collaborator)
153 .delete(remove_collaborator),
154 )
155 .route(
157 "/api/repos/{owner}/{name}/branches/{branch}/protection",
158 get(get_branch_protection)
159 .put(set_branch_protection)
160 .delete(remove_branch_protection),
161 )
162 .route(
164 "/api/repos/{owner}/{name}/hooks",
165 get(list_webhooks).post(create_webhook),
166 )
167 .route(
168 "/api/repos/{owner}/{name}/hooks/{id}",
169 get(get_webhook)
170 .patch(update_webhook)
171 .delete(delete_webhook),
172 )
173 .route(
174 "/api/repos/{owner}/{name}/hooks/{id}/ping",
175 post(ping_webhook),
176 )
177 .route(
179 "/api/repos/{owner}/{name}/permission/{user}",
180 get(check_permission),
181 )
182}
183
184#[derive(Debug, Deserialize)]
188pub struct CreateOrgRequest {
189 pub name: String,
190 pub display_name: String,
191 pub description: Option<String>,
192 pub creator: String,
193}
194
195#[derive(Debug, Deserialize)]
197pub struct UpdateOrgRequest {
198 pub display_name: Option<String>,
199 pub description: Option<String>,
200}
201
202#[derive(Debug, Serialize)]
204pub struct OrgResponse {
205 pub id: u64,
206 pub name: String,
207 pub display_name: String,
208 pub description: Option<String>,
209 pub created_by: String,
210 pub member_count: usize,
211 pub team_count: usize,
212 pub repo_count: usize,
213 pub created_at: u64,
214 pub updated_at: u64,
215}
216
217impl From<&Organization> for OrgResponse {
218 fn from(org: &Organization) -> Self {
219 Self {
220 id: org.id,
221 name: org.name.clone(),
222 display_name: org.display_name.clone(),
223 description: org.description.clone(),
224 created_by: org.created_by.clone(),
225 member_count: org.members.len(),
226 team_count: org.teams.len(),
227 repo_count: org.repos.len(),
228 created_at: org.created_at,
229 updated_at: org.updated_at,
230 }
231 }
232}
233
234#[derive(Debug, Deserialize)]
236pub struct AddOrgMemberRequest {
237 pub user: String,
238 pub role: String,
239 pub added_by: String,
240}
241
242#[derive(Debug, Serialize)]
244pub struct OrgMemberResponse {
245 pub user: String,
246 pub role: String,
247 pub added_at: u64,
248 pub added_by: String,
249}
250
251impl From<&OrgMember> for OrgMemberResponse {
252 fn from(m: &OrgMember) -> Self {
253 Self {
254 user: m.user.clone(),
255 role: m.role.to_string(),
256 added_at: m.added_at,
257 added_by: m.added_by.clone(),
258 }
259 }
260}
261
262#[derive(Debug, Deserialize)]
264pub struct CreateTeamRequest {
265 pub name: String,
266 pub description: Option<String>,
267 pub permission: String,
268 pub created_by: String,
269}
270
271#[derive(Debug, Deserialize)]
273pub struct UpdateTeamRequest {
274 pub name: Option<String>,
275 pub description: Option<String>,
276 pub permission: Option<String>,
277}
278
279#[derive(Debug, Serialize)]
281pub struct TeamResponse {
282 pub id: u64,
283 pub org_id: u64,
284 pub name: String,
285 pub description: Option<String>,
286 pub permission: String,
287 pub member_count: usize,
288 pub repo_count: usize,
289 pub created_at: u64,
290 pub updated_at: u64,
291}
292
293impl From<&Team> for TeamResponse {
294 fn from(t: &Team) -> Self {
295 Self {
296 id: t.id,
297 org_id: t.org_id,
298 name: t.name.clone(),
299 description: t.description.clone(),
300 permission: t.permission.to_string(),
301 member_count: t.members.len(),
302 repo_count: t.repos.len(),
303 created_at: t.created_at,
304 updated_at: t.updated_at,
305 }
306 }
307}
308
309#[derive(Debug, Deserialize)]
311pub struct AddCollaboratorRequest {
312 pub permission: String,
313 pub added_by: String,
314}
315
316#[derive(Debug, Serialize)]
318pub struct CollaboratorResponse {
319 pub user: String,
320 pub permission: String,
321 pub added_by: String,
322 pub created_at: u64,
323}
324
325impl From<&Collaborator> for CollaboratorResponse {
326 fn from(c: &Collaborator) -> Self {
327 Self {
328 user: c.user.clone(),
329 permission: c.permission.to_string(),
330 added_by: c.added_by.clone(),
331 created_at: c.created_at,
332 }
333 }
334}
335
336#[derive(Debug, Serialize)]
338pub struct BranchProtectionResponse {
339 pub id: u64,
340 pub pattern: String,
341 pub require_pr: bool,
342 pub required_reviews: u32,
343 pub required_status_checks: Vec<String>,
344 pub dismiss_stale_reviews: bool,
345 pub require_code_owner_review: bool,
346 pub restrict_pushes: bool,
347 pub allow_force_push: bool,
348 pub allow_deletion: bool,
349 pub created_at: u64,
350 pub updated_at: u64,
351}
352
353impl From<&BranchProtection> for BranchProtectionResponse {
354 fn from(bp: &BranchProtection) -> Self {
355 Self {
356 id: bp.id,
357 pattern: bp.pattern.clone(),
358 require_pr: bp.require_pr,
359 required_reviews: bp.required_reviews,
360 required_status_checks: bp.required_status_checks.iter().cloned().collect(),
361 dismiss_stale_reviews: bp.dismiss_stale_reviews,
362 require_code_owner_review: bp.require_code_owner_review,
363 restrict_pushes: bp.restrict_pushes,
364 allow_force_push: bp.allow_force_push,
365 allow_deletion: bp.allow_deletion,
366 created_at: bp.created_at,
367 updated_at: bp.updated_at,
368 }
369 }
370}
371
372#[derive(Debug, Serialize)]
374pub struct WebhookResponse {
375 pub id: u64,
376 pub url: String,
377 pub events: Vec<String>,
378 pub active: bool,
379 pub content_type: String,
380 pub delivery_count: u64,
381 pub failure_count: u64,
382 pub created_at: u64,
383 pub updated_at: u64,
384}
385
386impl From<&Webhook> for WebhookResponse {
387 fn from(w: &Webhook) -> Self {
388 Self {
389 id: w.id,
390 url: w.url.clone(),
391 events: w.events.iter().map(|e| e.to_string()).collect(),
392 active: w.active,
393 content_type: w.content_type.clone(),
394 delivery_count: w.delivery_count,
395 failure_count: w.failure_count,
396 created_at: w.created_at,
397 updated_at: w.updated_at,
398 }
399 }
400}
401
402#[derive(Debug, Serialize)]
404pub struct PermissionResponse {
405 pub user: String,
406 pub permission: Option<String>,
407 pub has_access: bool,
408}
409
410#[derive(Debug, Serialize)]
412struct ErrorResponse {
413 error: String,
414}
415
416struct AuthApiError(AuthError);
418
419impl From<AuthError> for AuthApiError {
420 fn from(err: AuthError) -> Self {
421 Self(err)
422 }
423}
424
425impl IntoResponse for AuthApiError {
426 fn into_response(self) -> axum::response::Response {
427 let (status, message) = match &self.0 {
428 AuthError::NotFound(_) => (StatusCode::NOT_FOUND, self.0.to_string()),
429 AuthError::PermissionDenied(_) => (StatusCode::FORBIDDEN, self.0.to_string()),
430 AuthError::AlreadyExists(_) => (StatusCode::CONFLICT, self.0.to_string()),
431 AuthError::InvalidInput(_) => (StatusCode::BAD_REQUEST, self.0.to_string()),
432 AuthError::LastOwner => (StatusCode::BAD_REQUEST, self.0.to_string()),
433 AuthError::BranchProtected(_, _) => (StatusCode::FORBIDDEN, self.0.to_string()),
434 AuthError::InvalidWebhook(_) => (StatusCode::BAD_REQUEST, self.0.to_string()),
435 AuthError::Serialization(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()),
436 };
437
438 (status, Json(ErrorResponse { error: message })).into_response()
439 }
440}
441
442async fn list_orgs(State(state): State<AppState>) -> impl IntoResponse {
446 let orgs = state.auth.list_organizations();
447 let responses: Vec<OrgResponse> = orgs.iter().map(Into::into).collect();
448 Json(responses)
449}
450
451async fn create_org(
453 State(state): State<AppState>,
454 Json(req): Json<CreateOrgRequest>,
455) -> Result<impl IntoResponse, AuthApiError> {
456 let mut org = state
457 .auth
458 .create_organization(req.name, req.display_name, req.creator)?;
459
460 if let Some(desc) = req.description {
461 org = state.auth.update_organization(org.id, None, Some(desc))?;
462 }
463
464 Ok((StatusCode::CREATED, Json(OrgResponse::from(&org))))
465}
466
467async fn get_org(
469 State(state): State<AppState>,
470 Path(org_name): Path<String>,
471) -> Result<impl IntoResponse, AuthApiError> {
472 let org = state
473 .auth
474 .get_organization_by_name(&org_name)
475 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
476
477 Ok(Json(OrgResponse::from(&org)))
478}
479
480async fn update_org(
482 State(state): State<AppState>,
483 Path(org_name): Path<String>,
484 Json(req): Json<UpdateOrgRequest>,
485) -> Result<impl IntoResponse, AuthApiError> {
486 let org = state
487 .auth
488 .get_organization_by_name(&org_name)
489 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
490
491 let updated = state
492 .auth
493 .update_organization(org.id, req.display_name, req.description)?;
494
495 Ok(Json(OrgResponse::from(&updated)))
496}
497
498async fn delete_org(
500 State(state): State<AppState>,
501 Path(org_name): Path<String>,
502) -> Result<impl IntoResponse, AuthApiError> {
503 let org = state
504 .auth
505 .get_organization_by_name(&org_name)
506 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
507
508 state.auth.delete_organization(org.id)?;
509
510 Ok(StatusCode::NO_CONTENT)
511}
512
513async fn list_org_members(
515 State(state): State<AppState>,
516 Path(org_name): Path<String>,
517) -> Result<impl IntoResponse, AuthApiError> {
518 let org = state
519 .auth
520 .get_organization_by_name(&org_name)
521 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
522
523 let responses: Vec<OrgMemberResponse> = org.members.iter().map(Into::into).collect();
524
525 Ok(Json(responses))
526}
527
528async fn add_org_member(
530 State(state): State<AppState>,
531 Path(org_name): Path<String>,
532 Json(req): Json<AddOrgMemberRequest>,
533) -> Result<impl IntoResponse, AuthApiError> {
534 let org = state
535 .auth
536 .get_organization_by_name(&org_name)
537 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
538
539 let role = OrgRole::parse(&req.role)
540 .ok_or_else(|| AuthError::InvalidInput(format!("invalid role: {}", req.role)))?;
541
542 let member = OrgMember::new(req.user, role, req.added_by);
543 state.auth.add_org_member(org.id, member.clone())?;
544
545 Ok((StatusCode::CREATED, Json(OrgMemberResponse::from(&member))))
546}
547
548async fn update_org_member(
550 State(state): State<AppState>,
551 Path((org_name, user)): Path<(String, String)>,
552 Json(req): Json<AddOrgMemberRequest>,
553) -> Result<impl IntoResponse, AuthApiError> {
554 let org = state
555 .auth
556 .get_organization_by_name(&org_name)
557 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
558
559 let role = OrgRole::parse(&req.role)
560 .ok_or_else(|| AuthError::InvalidInput(format!("invalid role: {}", req.role)))?;
561
562 state.auth.update_org_member_role(org.id, &user, role)?;
563
564 let org = state.auth.get_organization(org.id).unwrap();
566 let member = org
567 .get_member(&user)
568 .ok_or_else(|| AuthError::NotFound(format!("member '{}'", user)))?;
569
570 Ok(Json(OrgMemberResponse::from(member)))
571}
572
573async fn remove_org_member(
575 State(state): State<AppState>,
576 Path((org_name, user)): Path<(String, String)>,
577) -> Result<impl IntoResponse, AuthApiError> {
578 let org = state
579 .auth
580 .get_organization_by_name(&org_name)
581 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
582
583 state.auth.remove_org_member(org.id, &user)?;
584
585 Ok(StatusCode::NO_CONTENT)
586}
587
588async fn list_teams(
592 State(state): State<AppState>,
593 Path(org_name): Path<String>,
594) -> Result<impl IntoResponse, AuthApiError> {
595 let org = state
596 .auth
597 .get_organization_by_name(&org_name)
598 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
599
600 let teams = state.auth.list_teams(org.id);
601 let responses: Vec<TeamResponse> = teams.iter().map(Into::into).collect();
602
603 Ok(Json(responses))
604}
605
606async fn create_team(
608 State(state): State<AppState>,
609 Path(org_name): Path<String>,
610 Json(req): Json<CreateTeamRequest>,
611) -> Result<impl IntoResponse, AuthApiError> {
612 let org = state
613 .auth
614 .get_organization_by_name(&org_name)
615 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
616
617 let permission = Permission::parse(&req.permission).ok_or_else(|| {
618 AuthError::InvalidInput(format!("invalid permission: {}", req.permission))
619 })?;
620
621 let mut team = state
622 .auth
623 .create_team(org.id, req.name, permission, req.created_by)?;
624
625 if let Some(desc) = req.description {
626 team = state.auth.update_team(team.id, None, Some(desc), None)?;
627 }
628
629 Ok((StatusCode::CREATED, Json(TeamResponse::from(&team))))
630}
631
632async fn get_team(
634 State(state): State<AppState>,
635 Path((org_name, team_name)): Path<(String, String)>,
636) -> Result<impl IntoResponse, AuthApiError> {
637 let org = state
638 .auth
639 .get_organization_by_name(&org_name)
640 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
641
642 let team = state
643 .auth
644 .get_team_by_name(org.id, &team_name)
645 .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
646
647 Ok(Json(TeamResponse::from(&team)))
648}
649
650async fn update_team(
652 State(state): State<AppState>,
653 Path((org_name, team_name)): Path<(String, String)>,
654 Json(req): Json<UpdateTeamRequest>,
655) -> Result<impl IntoResponse, AuthApiError> {
656 let org = state
657 .auth
658 .get_organization_by_name(&org_name)
659 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
660
661 let team = state
662 .auth
663 .get_team_by_name(org.id, &team_name)
664 .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
665
666 let permission = req
667 .permission
668 .as_ref()
669 .map(|p| {
670 Permission::parse(p)
671 .ok_or_else(|| AuthError::InvalidInput(format!("invalid permission: {}", p)))
672 })
673 .transpose()?;
674
675 let updated = state
676 .auth
677 .update_team(team.id, req.name, req.description, permission)?;
678
679 Ok(Json(TeamResponse::from(&updated)))
680}
681
682async fn delete_team(
684 State(state): State<AppState>,
685 Path((org_name, team_name)): Path<(String, String)>,
686) -> Result<impl IntoResponse, AuthApiError> {
687 let org = state
688 .auth
689 .get_organization_by_name(&org_name)
690 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
691
692 let team = state
693 .auth
694 .get_team_by_name(org.id, &team_name)
695 .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
696
697 state.auth.delete_team(team.id)?;
698
699 Ok(StatusCode::NO_CONTENT)
700}
701
702async fn list_team_members(
704 State(state): State<AppState>,
705 Path((org_name, team_name)): Path<(String, String)>,
706) -> Result<impl IntoResponse, AuthApiError> {
707 let org = state
708 .auth
709 .get_organization_by_name(&org_name)
710 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
711
712 let team = state
713 .auth
714 .get_team_by_name(org.id, &team_name)
715 .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
716
717 let members: Vec<String> = team.members.iter().cloned().collect();
718
719 Ok(Json(members))
720}
721
722async fn add_team_member(
724 State(state): State<AppState>,
725 Path((org_name, team_name, user)): Path<(String, String, String)>,
726) -> Result<impl IntoResponse, AuthApiError> {
727 let org = state
728 .auth
729 .get_organization_by_name(&org_name)
730 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
731
732 let team = state
733 .auth
734 .get_team_by_name(org.id, &team_name)
735 .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
736
737 state.auth.add_team_member(team.id, user)?;
738
739 Ok(StatusCode::NO_CONTENT)
740}
741
742async fn remove_team_member(
744 State(state): State<AppState>,
745 Path((org_name, team_name, user)): Path<(String, String, String)>,
746) -> Result<impl IntoResponse, AuthApiError> {
747 let org = state
748 .auth
749 .get_organization_by_name(&org_name)
750 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
751
752 let team = state
753 .auth
754 .get_team_by_name(org.id, &team_name)
755 .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
756
757 state.auth.remove_team_member(team.id, &user)?;
758
759 Ok(StatusCode::NO_CONTENT)
760}
761
762async fn list_team_repos(
764 State(state): State<AppState>,
765 Path((org_name, team_name)): Path<(String, String)>,
766) -> Result<impl IntoResponse, AuthApiError> {
767 let org = state
768 .auth
769 .get_organization_by_name(&org_name)
770 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
771
772 let team = state
773 .auth
774 .get_team_by_name(org.id, &team_name)
775 .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
776
777 let repos: Vec<String> = team.repos.iter().cloned().collect();
778
779 Ok(Json(repos))
780}
781
782async fn add_team_repo(
784 State(state): State<AppState>,
785 Path((org_name, team_name, owner, name)): Path<(String, String, String, String)>,
786) -> Result<impl IntoResponse, AuthApiError> {
787 let org = state
788 .auth
789 .get_organization_by_name(&org_name)
790 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
791
792 let team = state
793 .auth
794 .get_team_by_name(org.id, &team_name)
795 .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
796
797 let repo_key = format!("{}/{}", owner, name);
798 state.auth.add_team_repo(team.id, repo_key)?;
799
800 Ok(StatusCode::NO_CONTENT)
801}
802
803async fn remove_team_repo(
805 State(state): State<AppState>,
806 Path((org_name, team_name, owner, name)): Path<(String, String, String, String)>,
807) -> Result<impl IntoResponse, AuthApiError> {
808 let org = state
809 .auth
810 .get_organization_by_name(&org_name)
811 .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
812
813 let team = state
814 .auth
815 .get_team_by_name(org.id, &team_name)
816 .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
817
818 let repo_key = format!("{}/{}", owner, name);
819 state.auth.remove_team_repo(team.id, &repo_key)?;
820
821 Ok(StatusCode::NO_CONTENT)
822}
823
824async fn list_collaborators(
828 State(state): State<AppState>,
829 Path((owner, name)): Path<(String, String)>,
830) -> impl IntoResponse {
831 let repo_key = format!("{}/{}", owner, name);
832 let collaborators = state.auth.list_collaborators(&repo_key);
833 let responses: Vec<CollaboratorResponse> = collaborators.iter().map(Into::into).collect();
834 Json(responses)
835}
836
837async fn get_collaborator(
839 State(state): State<AppState>,
840 Path((owner, name, user)): Path<(String, String, String)>,
841) -> Result<impl IntoResponse, AuthApiError> {
842 let repo_key = format!("{}/{}", owner, name);
843 let collaborator = state
844 .auth
845 .get_collaborator(&repo_key, &user)
846 .ok_or_else(|| AuthError::NotFound(format!("collaborator '{}' on '{}'", user, repo_key)))?;
847
848 Ok(Json(CollaboratorResponse::from(&collaborator)))
849}
850
851async fn add_collaborator(
853 State(state): State<AppState>,
854 Path((owner, name, user)): Path<(String, String, String)>,
855 Json(req): Json<AddCollaboratorRequest>,
856) -> Result<impl IntoResponse, AuthApiError> {
857 let repo_key = format!("{}/{}", owner, name);
858 let permission = Permission::parse(&req.permission).ok_or_else(|| {
859 AuthError::InvalidInput(format!("invalid permission: {}", req.permission))
860 })?;
861
862 let collaborator = state
863 .auth
864 .set_collaborator(repo_key, user, permission, req.added_by);
865
866 Ok((
867 StatusCode::CREATED,
868 Json(CollaboratorResponse::from(&collaborator)),
869 ))
870}
871
872async fn remove_collaborator(
874 State(state): State<AppState>,
875 Path((owner, name, user)): Path<(String, String, String)>,
876) -> Result<impl IntoResponse, AuthApiError> {
877 let repo_key = format!("{}/{}", owner, name);
878 state.auth.remove_collaborator(&repo_key, &user)?;
879
880 Ok(StatusCode::NO_CONTENT)
881}
882
883async fn get_branch_protection(
887 State(state): State<AppState>,
888 Path((owner, name, branch)): Path<(String, String, String)>,
889) -> Result<impl IntoResponse, AuthApiError> {
890 let repo_key = format!("{}/{}", owner, name);
891 let protection = state
892 .auth
893 .find_branch_protection(&repo_key, &branch)
894 .ok_or_else(|| {
895 AuthError::NotFound(format!(
896 "branch protection for '{}' on '{}'",
897 branch, repo_key
898 ))
899 })?;
900
901 Ok(Json(BranchProtectionResponse::from(&protection)))
902}
903
904async fn set_branch_protection(
906 State(state): State<AppState>,
907 Path((owner, name, branch)): Path<(String, String, String)>,
908 Json(req): Json<BranchProtectionRequest>,
909) -> Result<impl IntoResponse, AuthApiError> {
910 let repo_key = format!("{}/{}", owner, name);
911
912 let protection = state
914 .auth
915 .set_branch_protection(repo_key.clone(), branch.clone());
916
917 let updated = state
919 .auth
920 .update_branch_protection(&repo_key, &branch, |p| {
921 p.require_pr = req.require_pr;
922 p.required_reviews = req.required_reviews;
923 p.required_status_checks = req.required_status_checks.iter().cloned().collect();
924 p.dismiss_stale_reviews = req.dismiss_stale_reviews;
925 p.require_code_owner_review = req.require_code_owner_review;
926 p.restrict_pushes = req.restrict_pushes;
927 p.allow_force_push = req.allow_force_push;
928 p.allow_deletion = req.allow_deletion;
929 })?;
930
931 let status = if updated.id == protection.id {
933 StatusCode::OK
934 } else {
935 StatusCode::CREATED
936 };
937
938 Ok((status, Json(BranchProtectionResponse::from(&updated))))
939}
940
941async fn remove_branch_protection(
943 State(state): State<AppState>,
944 Path((owner, name, branch)): Path<(String, String, String)>,
945) -> Result<impl IntoResponse, AuthApiError> {
946 let repo_key = format!("{}/{}", owner, name);
947 state.auth.remove_branch_protection(&repo_key, &branch)?;
948
949 Ok(StatusCode::NO_CONTENT)
950}
951
952async fn list_webhooks(
956 State(state): State<AppState>,
957 Path((owner, name)): Path<(String, String)>,
958) -> impl IntoResponse {
959 let repo_key = format!("{}/{}", owner, name);
960 let webhooks = state.auth.list_webhooks(&repo_key);
961 let responses: Vec<WebhookResponse> = webhooks.iter().map(Into::into).collect();
962 Json(responses)
963}
964
965async fn create_webhook(
967 State(state): State<AppState>,
968 Path((owner, name)): Path<(String, String)>,
969 Json(req): Json<CreateWebhookRequest>,
970) -> Result<impl IntoResponse, AuthApiError> {
971 let repo_key = format!("{}/{}", owner, name);
972
973 let events: HashSet<WebhookEvent> = req
975 .events
976 .iter()
977 .filter_map(|e| WebhookEvent::parse(e))
978 .collect();
979
980 if events.is_empty() {
981 return Err(AuthError::InvalidInput("at least one valid event is required".into()).into());
982 }
983
984 let mut webhook = state.auth.create_webhook(repo_key, req.url, events);
985
986 if let Some(secret) = req.secret {
988 state
989 .auth
990 .update_webhook(webhook.id, |w| {
991 w.secret = Some(secret);
992 })
993 .ok();
994 webhook = state.auth.get_webhook(webhook.id).unwrap();
995 }
996
997 Ok((StatusCode::CREATED, Json(WebhookResponse::from(&webhook))))
998}
999
1000async fn get_webhook(
1002 State(state): State<AppState>,
1003 Path((owner, name, id)): Path<(String, String, u64)>,
1004) -> Result<impl IntoResponse, AuthApiError> {
1005 let repo_key = format!("{}/{}", owner, name);
1006 let webhook = state
1007 .auth
1008 .get_webhook(id)
1009 .filter(|w| w.repo_key == repo_key)
1010 .ok_or_else(|| AuthError::NotFound(format!("webhook {}", id)))?;
1011
1012 Ok(Json(WebhookResponse::from(&webhook)))
1013}
1014
1015async fn update_webhook(
1017 State(state): State<AppState>,
1018 Path((owner, name, id)): Path<(String, String, u64)>,
1019 Json(req): Json<UpdateWebhookRequest>,
1020) -> Result<impl IntoResponse, AuthApiError> {
1021 let repo_key = format!("{}/{}", owner, name);
1022
1023 let webhook = state
1025 .auth
1026 .get_webhook(id)
1027 .filter(|w| w.repo_key == repo_key)
1028 .ok_or_else(|| AuthError::NotFound(format!("webhook {}", id)))?;
1029
1030 let updated = state.auth.update_webhook(webhook.id, |w| {
1031 if let Some(url) = &req.url {
1032 w.url = url.clone();
1033 }
1034 if let Some(secret) = &req.secret {
1035 w.secret = Some(secret.clone());
1036 }
1037 if let Some(events) = &req.events {
1038 w.events = events
1039 .iter()
1040 .filter_map(|e| WebhookEvent::parse(e))
1041 .collect();
1042 }
1043 if let Some(active) = req.active {
1044 w.active = active;
1045 }
1046 })?;
1047
1048 Ok(Json(WebhookResponse::from(&updated)))
1049}
1050
1051async fn delete_webhook(
1053 State(state): State<AppState>,
1054 Path((owner, name, id)): Path<(String, String, u64)>,
1055) -> Result<impl IntoResponse, AuthApiError> {
1056 let repo_key = format!("{}/{}", owner, name);
1057
1058 let _webhook = state
1060 .auth
1061 .get_webhook(id)
1062 .filter(|w| w.repo_key == repo_key)
1063 .ok_or_else(|| AuthError::NotFound(format!("webhook {}", id)))?;
1064
1065 state.auth.delete_webhook(id)?;
1066
1067 Ok(StatusCode::NO_CONTENT)
1068}
1069
1070async fn ping_webhook(
1072 State(state): State<AppState>,
1073 Path((owner, name, id)): Path<(String, String, u64)>,
1074) -> Result<impl IntoResponse, AuthApiError> {
1075 let repo_key = format!("{}/{}", owner, name);
1076
1077 let webhook = state
1079 .auth
1080 .get_webhook(id)
1081 .filter(|w| w.repo_key == repo_key)
1082 .ok_or_else(|| AuthError::NotFound(format!("webhook {}", id)))?;
1083
1084 Ok(Json(serde_json::json!({
1087 "id": webhook.id,
1088 "url": webhook.url,
1089 "message": "Ping sent successfully"
1090 })))
1091}
1092
1093async fn check_permission(
1097 State(state): State<AppState>,
1098 Path((owner, name, user)): Path<(String, String, String)>,
1099) -> impl IntoResponse {
1100 let repo_key = format!("{}/{}", owner, name);
1101 let permission = state.auth.get_effective_permission(&user, &repo_key);
1102
1103 Json(PermissionResponse {
1104 user,
1105 permission: permission.map(|p| p.to_string()),
1106 has_access: permission.is_some(),
1107 })
1108}