Skip to content
Clone

feat(git): add star/watch/topic/license/whitelist handlers, services and graphql types

ZhenYi committed
parent 32cbdae commit 43c41af Browse files

28 files changed

+2015 −9

File tree 28
Modified lib/git/src/graphql/query/repo.rs View lib/git/src/graphql/query/repo.rs
@@ -6,12 +6,13 @@
66use gitbaby::setup::types::ObjectFormat;
77
88use crate::graphql::error::map_git_baby;
9use crate::graphql::scalars::DateTimeScalar;
910use crate::graphql::types::{
1011 BlameObj, BlobObj, ChangedPathDiffObj, CodeActivityObj, CommitObj, CommitStatusObj,
1112 ComparisonObj, ComparisonStatus, ContributorStatObj, DiffObj, DiffSummaryObj, GitObject,
12 LanguageStatObj, LanguageStatsObj, LfsPointerObj, LogOptionsInput, MergeStatusObj,
13 LanguageStatObj, LanguageStatsObj, LicenseObj, LfsPointerObj, LogOptionsInput, MergeStatusObj,
1314 ObjectFormatEnum, PackStatsObj, PageInput, PatchObj, RefObj, RepoStatsObj, RepositoryInfoObj,
14 SubmoduleObj, TagObj, TagShape, TagTypeEnum, TreeObj,
15 SubmoduleObj, TagObj, TagShape, TagTypeEnum, TopicObj, TreeObj,
1516};
1617use crate::service::diffs::DiffOptionsDto;
1718
@@ -53,9 +54,51 @@
5354 nums_branches: self.inner.ctx.model.nums_branches,
5455 nums_tags: self.inner.ctx.model.nums_tags,
5556 nums_commits: self.inner.ctx.model.nums_commits,
57 nums_star: self.inner.ctx.model.nums_star,
58 nums_watch: self.inner.ctx.model.nums_watch,
59 license_name: self.inner.ctx.model.license_name.clone(),
5660 }
5761 }
5862
63 async fn license(&self) -> Result<Option<LicenseObj>, async_graphql::Error> {
64 let Some(name) = self.inner.ctx.model.license_name.clone() else {
65 return Ok(None);
66 };
67 let dto = self
68 .inner
69 .svc
70 .get_license(&name)
71 .await
72 .map_err(map_git)?;
73 Ok(Some(LicenseObj::from(models::repo::repo_license::Model {
74 name: dto.name,
75 display_name: dto.display_name,
76 description: dto.description,
77 body: dto.body,
78 url: dto.url,
79 created_at: dto.created_at,
80 updated_at: dto.updated_at,
81 })))
82 }
83
84 async fn topics(&self) -> Result<Vec<TopicObj>, async_graphql::Error> {
85 let dtos = self
86 .inner
87 .svc
88 .list_repo_topics(&self.inner.ctx)
89 .await
90 .map_err(map_git)?;
91 Ok(dtos
92 .into_iter()
93 .map(|t| TopicObj {
94 id: t.id.to_string(),
95 name: t.name,
96 created_at: DateTimeScalar::from(t.created_at),
97 updated_at: DateTimeScalar::from(t.updated_at),
98 })
99 .collect())
100 }
101
59102 async fn repo_stats(&self) -> Result<RepoStatsObj, async_graphql::Error> {
60103 let paths = self.inner.ctx.repo_dir();
61104 let timeout = self
Modified lib/git/src/graphql/query/root.rs View lib/git/src/graphql/query/root.rs
@@ -2,6 +2,7 @@
22use std::sync::Arc;
33use uuid::Uuid;
44
5use crate::graphql::types::LicenseObj;
56use crate::service::GitService;
67use crate::share::access::authorize_repo_access;
78use crate::share::errors::GitError;
@@ -19,6 +20,24 @@
1920 "1.0.0"
2021 }
2122
23 async fn license(
24 &self,
25 ctx: &Context<'_>,
26 name: String,
27 ) -> Result<LicenseObj, async_graphql::Error> {
28 let svc = ctx.data_unchecked::<Arc<GitService>>().clone();
29 let dto = svc.get_license(&name).await.map_err(map_git)?;
30 Ok(LicenseObj::from(models::repo::repo_license::Model {
31 name: dto.name,
32 display_name: dto.display_name,
33 description: dto.description,
34 body: dto.body,
35 url: dto.url,
36 created_at: dto.created_at,
37 updated_at: dto.updated_at,
38 }))
39 }
40
2241 async fn repo(
2342 &self,
2443 ctx: &Context<'_>,
Added lib/git/src/graphql/types/license.rs View lib/git/src/graphql/types/license.rs
@@ -0,0 +1,28 @@
1use async_graphql::SimpleObject;
2
3use crate::graphql::scalars::DateTimeScalar;
4
5#[derive(SimpleObject, Clone, Debug)]
6pub struct LicenseObj {
7 pub name: String,
8 pub display_name: String,
9 pub description: Option<String>,
10 pub body: String,
11 pub url: Option<String>,
12 pub created_at: DateTimeScalar,
13 pub updated_at: DateTimeScalar,
14}
15
16impl From<models::repo::repo_license::Model> for LicenseObj {
17 fn from(m: models::repo::repo_license::Model) -> Self {
18 Self {
19 name: m.name,
20 display_name: m.display_name,
21 description: m.description,
22 body: m.body,
23 url: m.url,
24 created_at: DateTimeScalar::from(m.created_at),
25 updated_at: DateTimeScalar::from(m.updated_at),
26 }
27 }
28}
No newline at end of file
Modified lib/git/src/graphql/types/mod.rs View lib/git/src/graphql/types/mod.rs
@@ -6,6 +6,7 @@
66mod diff;
77mod enums;
88mod inputs;
9mod license;
910mod merge;
1011mod objects;
1112mod patch;
@@ -13,6 +14,7 @@
1314mod repo;
1415mod submodule;
1516mod tag;
17mod topic;
1618mod tree;
1719
1820pub use analysis::{
@@ -28,6 +30,7 @@
2830 ObjectFormatEnum, RefTypeEnum, TagTypeEnum, TreeEntryType,
2931};
3032pub use inputs::{LogOptionsInput, PageInput};
33pub use license::LicenseObj;
3134pub use merge::MergeStatusObj;
3235pub use objects::GitObject;
3336pub use patch::PatchObj;
@@ -35,4 +38,5 @@
3538pub use repo::{ComparisonObj, PackStatsObj, RepoStatsObj, RepositoryInfoObj};
3639pub use submodule::SubmoduleObj;
3740pub use tag::{TagObj, TagShape};
41pub use topic::{RepoRelationTopicObj, TopicObj};
3842pub use tree::{TreeEntryObj, TreeEntryWithLatestObj, TreeObj};
Modified lib/git/src/graphql/types/repo.rs View lib/git/src/graphql/types/repo.rs
@@ -15,6 +15,9 @@
1515 pub nums_branches: i32,
1616 pub nums_tags: i32,
1717 pub nums_commits: i32,
18 pub nums_star: i32,
19 pub nums_watch: i32,
20 pub license_name: Option<String>,
1821}
1922
2023#[derive(SimpleObject, Clone, Debug)]
Added lib/git/src/graphql/types/topic.rs View lib/git/src/graphql/types/topic.rs
@@ -0,0 +1,41 @@
1use async_graphql::SimpleObject;
2
3use crate::graphql::scalars::DateTimeScalar;
4
5#[derive(SimpleObject, Clone, Debug)]
6pub struct TopicObj {
7 pub id: String,
8 pub name: String,
9 pub created_at: DateTimeScalar,
10 pub updated_at: DateTimeScalar,
11}
12
13impl From<models::repo::repo_topic::Model> for TopicObj {
14 fn from(m: models::repo::repo_topic::Model) -> Self {
15 Self {
16 id: m.id.to_string(),
17 name: m.name,
18 created_at: DateTimeScalar::from(m.created_at),
19 updated_at: DateTimeScalar::from(m.updated_at),
20 }
21 }
22}
23
24#[derive(SimpleObject, Clone, Debug)]
25pub struct RepoRelationTopicObj {
26 pub id: String,
27 pub repo_id: String,
28 pub topic_id: String,
29 pub created_at: DateTimeScalar,
30}
31
32impl From<models::repo::repo_relation_topic::Model> for RepoRelationTopicObj {
33 fn from(m: models::repo::repo_relation_topic::Model) -> Self {
34 Self {
35 id: m.id.to_string(),
36 repo_id: m.repo_id.to_string(),
37 topic_id: m.topic_id.to_string(),
38 created_at: DateTimeScalar::from(m.created_at),
39 }
40 }
41}
No newline at end of file
Modified lib/git/src/graphql/scalars.rs View lib/git/src/graphql/scalars.rs
@@ -111,6 +111,15 @@
111111 }
112112}
113113
114impl From<time::OffsetDateTime> for DateTimeScalar {
115 fn from(dt: time::OffsetDateTime) -> Self {
116 DateTimeScalar {
117 timestamp: dt.unix_timestamp(),
118 offset_seconds: dt.offset().whole_seconds(),
119 }
120 }
121}
122
114123/// Bytes ↔ base64 string for blob payloads.
115124#[derive(Clone, Debug)]
116125pub struct Base64Scalar(pub Vec<u8>);
Added lib/git/src/handler/licenses.rs View lib/git/src/handler/licenses.rs
@@ -0,0 +1,75 @@
1use axum::extract::{Path, Query, State};
2use axum::Json;
3use export::rest::AppResponseData;
4use serde::Deserialize;
5use utoipa::IntoParams;
6
7use crate::errors::{ApiError, GitApiError};
8use crate::service::GitService;
9use crate::service::licenses::{LicenseDto, UpsertLicenseCmd};
10
11#[derive(Debug, Clone, Deserialize, IntoParams)]
12pub struct LicenseNamePath {
13 pub name: String,
14}
15
16#[derive(Debug, Clone, Deserialize, IntoParams)]
17pub struct LicenseListQuery {
18 pub limit: Option<u64>,
19 pub offset: Option<u64>,
20}
21
22#[utoipa::path(
23 get,
24 path = "/licenses",
25 tag = "licenses",
26 params(LicenseListQuery),
27 responses(
28 (status = 200, description = "list licenses", body = AppResponseData<Vec<LicenseDto>>)
29 )
30)]
31pub async fn list_licenses(
32 State(service): State<GitService>,
33 Query(q): Query<LicenseListQuery>,
34) -> Result<AppResponseData<Vec<LicenseDto>>, ApiError> {
35 let limit = q.limit.unwrap_or(50).clamp(1, 100);
36 let offset = q.offset.unwrap_or(0);
37 let out = service.list_licenses(limit, offset).await.map_err(GitApiError::from)?;
38 Ok(AppResponseData::ok(out))
39}
40
41#[utoipa::path(
42 get,
43 path = "/licenses/{name}",
44 tag = "licenses",
45 params(LicenseNamePath),
46 responses(
47 (status = 200, description = "get license", body = AppResponseData<LicenseDto>)
48 )
49)]
50pub async fn get_license(
51 State(service): State<GitService>,
52 Path(p): Path<LicenseNamePath>,
53) -> Result<AppResponseData<LicenseDto>, ApiError> {
54 let out = service.get_license(&p.name).await.map_err(GitApiError::from)?;
55 Ok(AppResponseData::ok(out))
56}
57
58#[utoipa::path(
59 put,
60 path = "/licenses/{name}",
61 tag = "licenses",
62 params(LicenseNamePath),
63 request_body = UpsertLicenseCmd,
64 responses(
65 (status = 200, description = "upsert license", body = AppResponseData<LicenseDto>)
66 )
67)]
68pub async fn upsert_license(
69 State(service): State<GitService>,
70 Path(p): Path<LicenseNamePath>,
71 Json(cmd): Json<UpsertLicenseCmd>,
72) -> Result<AppResponseData<LicenseDto>, ApiError> {
73 let out = service.upsert_license(&p.name, cmd).await.map_err(GitApiError::from)?;
74 Ok(AppResponseData::ok(out))
75}
No newline at end of file
Modified lib/git/src/handler/mod.rs View lib/git/src/handler/mod.rs
@@ -9,6 +9,7 @@
99pub mod diffs;
1010pub mod graphql;
1111pub mod hooks;
12pub mod licenses;
1213pub mod lfs;
1314pub mod logs;
1415pub mod maintenance;
@@ -21,9 +22,13 @@
2122pub mod repos;
2223pub mod share;
2324pub mod signatures;
25pub mod stars;
2426pub mod submodules;
2527pub mod tags;
28pub mod topics;
2629pub mod trees;
30pub mod watches;
31pub mod whitelists;
2732
2833pub use share::{
2934 RepoPath, RepoPathRev, ensure_caller, require_repo_admin, require_writer, resolve_reader,
Added lib/git/src/handler/stars.rs View lib/git/src/handler/stars.rs
@@ -0,0 +1,109 @@
1use axum::extract::{Path, Query, State};
2use export::rest::AppResponseData;
3use serde::{Deserialize, Serialize};
4use session::Session;
5use utoipa::IntoParams;
6
7use crate::errors::{ApiError, GitApiError};
8use crate::handler::{RepoPath, require_writer};
9use crate::service::GitService;
10use crate::service::stars::{RepoStarResp, StargazerDto};
11
12#[derive(Debug, Clone, Deserialize, IntoParams)]
13pub struct StarsPath {
14 pub space_name: String,
15 pub repo_name: String,
16}
17
18#[derive(Debug, Clone, Deserialize, IntoParams)]
19pub struct StarQuery {
20 pub limit: Option<u64>,
21 pub offset: Option<u64>,
22}
23
24#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
25pub struct StarStatusResp {
26 pub starred: bool,
27}
28
29#[utoipa::path(
30 put,
31 path = "/repos/{space_name}/{repo_name}/star",
32 tag = "repos",
33 params(RepoPath),
34 responses(
35 (status = 200, description = "star repo", body = AppResponseData<RepoStarResp>)
36 )
37)]
38pub async fn star_repo(
39 State(service): State<GitService>,
40 session: Session,
41 Path(path): Path<RepoPath>,
42) -> Result<AppResponseData<RepoStarResp>, ApiError> {
43 let (ctx, uid) = require_writer(&service, &session, path.space_name.clone(), path.repo_name.clone()).await?;
44 let out = service.star_repo(&ctx, uid).await.map_err(GitApiError::from)?;
45 Ok(AppResponseData::ok(out))
46}
47
48#[utoipa::path(
49 delete,
50 path = "/repos/{space_name}/{repo_name}/star",
51 tag = "repos",
52 params(RepoPath),
53 responses(
54 (status = 200, description = "unstar repo", body = AppResponseData<RepoStarResp>)
55 )
56)]
57pub async fn unstar_repo(
58 State(service): State<GitService>,
59 session: Session,
60 Path(path): Path<RepoPath>,
61) -> Result<AppResponseData<RepoStarResp>, ApiError> {
62 let (ctx, uid) = require_writer(&service, &session, path.space_name.clone(), path.repo_name.clone()).await?;
63 let out = service.unstar_repo(&ctx, uid).await.map_err(GitApiError::from)?;
64 Ok(AppResponseData::ok(out))
65}
66
67#[utoipa::path(
68 get,
69 path = "/repos/{space_name}/{repo_name}/star",
70 tag = "repos",
71 params(RepoPath),
72 responses(
73 (status = 200, description = "check star status", body = AppResponseData<StarStatusResp>)
74 )
75)]
76pub async fn is_starred(
77 State(service): State<GitService>,
78 session: Session,
79 Path(path): Path<RepoPath>,
80) -> Result<AppResponseData<StarStatusResp>, ApiError> {
81 let (ctx, uid) = require_writer(&service, &session, path.space_name.clone(), path.repo_name.clone()).await?;
82 let starred = service.is_starred(&ctx, uid).await.map_err(GitApiError::from)?;
83 Ok(AppResponseData::ok(StarStatusResp { starred }))
84}
85
86#[utoipa::path(
87 get,
88 path = "/repos/{space_name}/{repo_name}/stargazers",
89 tag = "repos",
90 params(RepoPath, StarQuery),
91 responses(
92 (status = 200, description = "list stargazers", body = AppResponseData<Vec<StargazerDto>>)
93 )
94)]
95pub async fn list_stargazers(
96 State(service): State<GitService>,
97 session: Session,
98 Path(path): Path<RepoPath>,
99 Query(q): Query<StarQuery>,
100) -> Result<AppResponseData<Vec<StargazerDto>>, ApiError> {
101 let (ctx, _uid) = require_writer(&service, &session, path.space_name.clone(), path.repo_name.clone()).await?;
102 let limit = q.limit.unwrap_or(50).clamp(1, 100);
103 let offset = q.offset.unwrap_or(0);
104 let out = service.list_stargazers(&ctx, limit, offset).await.map_err(GitApiError::from)?;
105 Ok(AppResponseData::ok(out))
106}
107
108#[allow(dead_code)]
109fn _typecheck(_: AppResponseData<RepoStarResp>) {}
No newline at end of file
Added lib/git/src/handler/topics.rs View lib/git/src/handler/topics.rs
@@ -0,0 +1,164 @@
1use axum::extract::{Path, Query, State};
2use axum::Json;
3use export::rest::{AppResponseData, NoData};
4use serde::{Deserialize, Serialize};
5use session::Session;
6use utoipa::IntoParams;
7
8use crate::errors::{ApiError, GitApiError};
9use crate::handler::{RepoPath, require_writer, resolve_reader};
10use crate::service::GitService;
11use crate::service::topics::{CreateTopicCmd, TopicDto, TopicRepoDto};
12
13#[derive(Debug, Clone, Deserialize, IntoParams)]
14pub struct TopicNamePath {
15 pub name: String,
16}
17
18#[derive(Debug, Clone, Deserialize, IntoParams)]
19pub struct TopicListQuery {
20 pub limit: Option<u64>,
21 pub offset: Option<u64>,
22}
23
24#[derive(Debug, Clone, Deserialize, IntoParams, utoipa::ToSchema)]
25pub struct RepoTopicPath {
26 pub space_name: String,
27 pub repo_name: String,
28 pub topic_name: String,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub struct TopicOkResp {
33 pub topic: TopicDto,
34}
35
36#[utoipa::path(
37 post,
38 path = "/topics",
39 tag = "topics",
40 request_body = CreateTopicCmd,
41 responses(
42 (status = 200, description = "create topic", body = AppResponseData<TopicDto>)
43 )
44)]
45pub async fn create_topic(
46 State(service): State<GitService>,
47 Json(cmd): Json<CreateTopicCmd>,
48) -> Result<AppResponseData<TopicDto>, ApiError> {
49 let out = service.create_topic(cmd.name).await.map_err(GitApiError::from)?;
50 Ok(AppResponseData::ok(out))
51}
52
53#[utoipa::path(
54 get,
55 path = "/topics",
56 tag = "topics",
57 params(TopicListQuery),
58 responses(
59 (status = 200, description = "list topics", body = AppResponseData<Vec<TopicDto>>)
60 )
61)]
62pub async fn list_topics(
63 State(service): State<GitService>,
64 Query(q): Query<TopicListQuery>,
65) -> Result<AppResponseData<Vec<TopicDto>>, ApiError> {
66 let limit = q.limit.unwrap_or(50).clamp(1, 100);
67 let offset = q.offset.unwrap_or(0);
68 let out = service.list_topics(limit, offset).await.map_err(GitApiError::from)?;
69 Ok(AppResponseData::ok(out))
70}
71
72#[utoipa::path(
73 get,
74 path = "/topics/{name}",
75 tag = "topics",
76 params(TopicNamePath),
77 responses(
78 (status = 200, description = "get topic", body = AppResponseData<TopicDto>)
79 )
80)]
81pub async fn get_topic(
82 State(service): State<GitService>,
83 Path(p): Path<TopicNamePath>,
84) -> Result<AppResponseData<TopicDto>, ApiError> {
85 let out = service.get_topic(&p.name).await.map_err(GitApiError::from)?;
86 Ok(AppResponseData::ok(out))
87}
88
89#[utoipa::path(
90 get,
91 path = "/topics/{name}/repos",
92 tag = "topics",
93 params(TopicNamePath, TopicListQuery),
94 responses(
95 (status = 200, description = "repos using topic", body = AppResponseData<Vec<TopicRepoDto>>)
96 )
97)]
98pub async fn list_repos_by_topic(
99 State(service): State<GitService>,
100 Path(p): Path<TopicNamePath>,
101 Query(q): Query<TopicListQuery>,
102) -> Result<AppResponseData<Vec<TopicRepoDto>>, ApiError> {
103 let limit = q.limit.unwrap_or(50).clamp(1, 100);
104 let offset = q.offset.unwrap_or(0);
105 let out = service.list_repos_by_topic(&p.name, limit, offset).await.map_err(GitApiError::from)?;
106 Ok(AppResponseData::ok(out))
107}
108
109#[utoipa::path(
110 put,
111 path = "/repos/{space_name}/{repo_name}/topics/{topic_name}",
112 tag = "repos",
113 params(RepoTopicPath),
114 responses(
115 (status = 200, description = "add topic to repo", body = AppResponseData<TopicDto>)
116 )
117)]
118pub async fn add_topic_to_repo(
119 State(service): State<GitService>,
120 session: Session,
121 Path(p): Path<RepoTopicPath>,
122) -> Result<AppResponseData<TopicDto>, ApiError> {
123 let (ctx, _uid) = require_writer(&service, &session, p.space_name.clone(), p.repo_name.clone()).await?;
124 let out = service.add_topic_to_repo(&ctx, &p.topic_name).await.map_err(GitApiError::from)?;
125 Ok(AppResponseData::ok(out))
126}
127
128#[utoipa::path(
129 delete,
130 path = "/repos/{space_name}/{repo_name}/topics/{topic_name}",
131 tag = "repos",
132 params(RepoTopicPath),
133 responses(
134 (status = 200, description = "remove topic from repo", body = AppResponseData<NoData>)
135 )
136)]
137pub async fn remove_topic_from_repo(
138 State(service): State<GitService>,
139 session: Session,
140 Path(p): Path<RepoTopicPath>,
141) -> Result<AppResponseData<NoData>, ApiError> {
142 let (ctx, _uid) = require_writer(&service, &session, p.space_name.clone(), p.repo_name.clone()).await?;
143 service.remove_topic_from_repo(&ctx, &p.topic_name).await.map_err(GitApiError::from)?;
144 Ok(AppResponseData::empty())
145}
146
147#[utoipa::path(
148 get,
149 path = "/repos/{space_name}/{repo_name}/topics",
150 tag = "repos",
151 params(RepoPath),
152 responses(
153 (status = 200, description = "list repo topics", body = AppResponseData<Vec<TopicDto>>)
154 )
155)]
156pub async fn list_repo_topics(
157 State(service): State<GitService>,
158 session: Session,
159 Path(path): Path<RepoPath>,
160) -> Result<AppResponseData<Vec<TopicDto>>, ApiError> {
161 let (ctx, _uid) = resolve_reader(&service, &session, path.space_name.clone(), path.repo_name.clone()).await?;
162 let out = service.list_repo_topics(&ctx).await.map_err(GitApiError::from)?;
163 Ok(AppResponseData::ok(out))
164}
No newline at end of file
Added lib/git/src/handler/watches.rs View lib/git/src/handler/watches.rs
@@ -0,0 +1,109 @@
1use axum::extract::{Path, Query, State};
2use axum::Json;
3use export::rest::{AppRequestData, AppResponseData};
4use models::common::watch_mode::WatchMode;
5use serde::Deserialize;
6use session::Session;
7use utoipa::IntoParams;
8
9use crate::errors::{ApiError, GitApiError};
10use crate::handler::{RepoPath, require_writer};
11use crate::service::GitService;
12use crate::service::watches::{RepoWatchDto, UpsertWatchCmd, WatcherDto};
13
14#[derive(Debug, Clone, Deserialize, IntoParams)]
15pub struct WatchQuery {
16 pub limit: Option<u64>,
17 pub offset: Option<u64>,
18 pub mode: Option<WatchMode>,
19}
20
21#[utoipa::path(
22 put,
23 path = "/repos/{space_name}/{repo_name}/watch",
24 tag = "repos",
25 params(RepoPath),
26 request_body = UpsertWatchCmd,
27 responses(
28 (status = 200, description = "upsert watch", body = AppResponseData<RepoWatchDto>)
29 )
30)]
31pub async fn upsert_watch(
32 State(service): State<GitService>,
33 session: Session,
34 Path(path): Path<RepoPath>,
35 Json(cmd): Json<UpsertWatchCmd>,
36) -> Result<AppResponseData<RepoWatchDto>, ApiError> {
37 let (ctx, uid) = require_writer(&service, &session, path.space_name.clone(), path.repo_name.clone()).await?;
38 let out = service.upsert_watch(&ctx, uid, cmd).await.map_err(GitApiError::from)?;
39 Ok(AppResponseData::ok(out))
40}
41
42#[utoipa::path(
43 delete,
44 path = "/repos/{space_name}/{repo_name}/watch",
45 tag = "repos",
46 params(RepoPath),
47 responses(
48 (status = 200, description = "unwatch repo", body = AppResponseData<RepoWatchDto>)
49 )
50)]
51pub async fn unwatch(
52 State(service): State<GitService>,
53 session: Session,
54 Path(path): Path<RepoPath>,
55) -> Result<AppResponseData<RepoWatchDto>, ApiError> {
56 let (ctx, uid) = require_writer(&service, &session, path.space_name.clone(), path.repo_name.clone()).await?;
57 service.unwatch(&ctx, uid).await.map_err(GitApiError::from)?;
58 Ok(AppResponseData::ok(RepoWatchDto {
59 user_id: uid,
60 repo_id: ctx.model.id,
61 mode: WatchMode::NoWatch,
62 created_at: time::OffsetDateTime::now_utc(),
63 updated_at: time::OffsetDateTime::now_utc(),
64 }))
65}
66
67#[utoipa::path(
68 get,
69 path = "/repos/{space_name}/{repo_name}/watch",
70 tag = "repos",
71 params(RepoPath),
72 responses(
73 (status = 200, description = "current watch mode", body = AppResponseData<Option<WatchMode>>)
74 )
75)]
76pub async fn get_watch_mode(
77 State(service): State<GitService>,
78 session: Session,
79 Path(path): Path<RepoPath>,
80) -> Result<AppResponseData<Option<WatchMode>>, ApiError> {
81 let (ctx, uid) = require_writer(&service, &session, path.space_name.clone(), path.repo_name.clone()).await?;
82 let mode = service.get_watch_mode(&ctx, uid).await.map_err(GitApiError::from)?;
83 Ok(AppResponseData::ok(mode))
84}
85
86#[utoipa::path(
87 get,
88 path = "/repos/{space_name}/{repo_name}/watchers",
89 tag = "repos",
90 params(RepoPath, WatchQuery),
91 responses(
92 (status = 200, description = "list watchers", body = AppResponseData<Vec<WatcherDto>>)
93 )
94)]
95pub async fn list_watchers(
96 State(service): State<GitService>,
97 session: Session,
98 Path(path): Path<RepoPath>,
99 Query(q): Query<WatchQuery>,
100) -> Result<AppResponseData<Vec<WatcherDto>>, ApiError> {
101 let (ctx, _uid) = require_writer(&service, &session, path.space_name.clone(), path.repo_name.clone()).await?;
102 let limit = q.limit.unwrap_or(50).clamp(1, 100);
103 let offset = q.offset.unwrap_or(0);
104 let out = service.list_watchers(&ctx, q.mode, limit, offset).await.map_err(GitApiError::from)?;
105 Ok(AppResponseData::ok(out))
106}
107
108#[allow(dead_code)]
109fn _typecheck(_: AppRequestData<UpsertWatchCmd>) {}
No newline at end of file
Added lib/git/src/handler/whitelists.rs View lib/git/src/handler/whitelists.rs
@@ -0,0 +1,151 @@
1use axum::extract::{Path, State};
2use axum::Json;
3use export::rest::{AppRequestData, AppResponseData, NoData};
4use serde::Deserialize;
5use utoipa::IntoParams;
6use uuid::Uuid;
7
8use crate::errors::{ApiError, GitApiError};
9use crate::service::GitService;
10use crate::service::whitelists::{
11 AddBranchWhitelistCmd, AddTagWhitelistCmd, BranchWhitelistDto, TagWhitelistDto,
12};
13
14#[derive(Debug, Clone, Deserialize, IntoParams)]
15pub struct BranchPolicyPath {
16 pub space_name: String,
17 pub repo_name: String,
18 pub policy_id: Uuid,
19}
20
21#[derive(Debug, Clone, Deserialize, IntoParams)]
22pub struct BranchWhitelistPath {
23 pub space_name: String,
24 pub repo_name: String,
25 pub policy_id: Uuid,
26 pub whitelist_id: Uuid,
27}
28
29#[derive(Debug, Clone, Deserialize, IntoParams)]
30pub struct TagPolicyPath {
31 pub space_name: String,
32 pub repo_name: String,
33 pub policy_id: Uuid,
34}
35
36#[derive(Debug, Clone, Deserialize, IntoParams)]
37pub struct TagWhitelistPath {
38 pub space_name: String,
39 pub repo_name: String,
40 pub policy_id: Uuid,
41 pub user_id: Uuid,
42}
43
44#[utoipa::path(
45 post,
46 path = "/repos/{space_name}/{repo_name}/branch-protection/{policy_id}/whitelists",
47 tag = "branch_protection",
48 params(BranchPolicyPath),
49 request_body = AddBranchWhitelistCmd,
50 responses(
51 (status = 200, description = "add branch whitelist", body = AppResponseData<BranchWhitelistDto>)
52 )
53)]
54pub async fn add_branch_whitelist(
55 State(service): State<GitService>,
56 Path(p): Path<BranchPolicyPath>,
57 Json(cmd): Json<AddBranchWhitelistCmd>,
58) -> Result<AppResponseData<BranchWhitelistDto>, ApiError> {
59 let out = service.add_branch_whitelist(p.policy_id, cmd).await.map_err(GitApiError::from)?;
60 Ok(AppResponseData::ok(out))
61}
62
63#[utoipa::path(
64 delete,
65 path = "/repos/{space_name}/{repo_name}/branch-protection/{policy_id}/whitelists/{whitelist_id}",
66 tag = "branch_protection",
67 params(BranchWhitelistPath),
68 responses(
69 (status = 200, description = "remove branch whitelist", body = AppResponseData<NoData>)
70 )
71)]
72pub async fn remove_branch_whitelist(
73 State(service): State<GitService>,
74 Path(p): Path<BranchWhitelistPath>,
75) -> Result<AppResponseData<NoData>, ApiError> {
76 service.remove_branch_whitelist(p.policy_id, p.whitelist_id).await.map_err(GitApiError::from)?;
77 Ok(AppResponseData::empty())
78}
79
80#[utoipa::path(
81 get,
82 path = "/repos/{space_name}/{repo_name}/branch-protection/{policy_id}/whitelists",
83 tag = "branch_protection",
84 params(BranchPolicyPath),
85 responses(
86 (status = 200, description = "list branch whitelists", body = AppResponseData<Vec<BranchWhitelistDto>>)
87 )
88)]
89pub async fn list_branch_whitelists(
90 State(service): State<GitService>,
91 Path(p): Path<BranchPolicyPath>,
92) -> Result<AppResponseData<Vec<BranchWhitelistDto>>, ApiError> {
93 let out = service.list_branch_whitelists(p.policy_id).await.map_err(GitApiError::from)?;
94 Ok(AppResponseData::ok(out))
95}
96
97#[utoipa::path(
98 post,
99 path = "/repos/{space_name}/{repo_name}/tag-protection/{policy_id}/whitelists",
100 tag = "tag_protection",
101 params(TagPolicyPath),
102 request_body = AddTagWhitelistCmd,
103 responses(
104 (status = 200, description = "add tag whitelist", body = AppResponseData<TagWhitelistDto>)
105 )
106)]
107pub async fn add_tag_whitelist(
108 State(service): State<GitService>,
109 Path(p): Path<TagPolicyPath>,
110 Json(cmd): Json<AddTagWhitelistCmd>,
111) -> Result<AppResponseData<TagWhitelistDto>, ApiError> {
112 let out = service.add_tag_whitelist(p.policy_id, cmd).await.map_err(GitApiError::from)?;
113 Ok(AppResponseData::ok(out))
114}
115
116#[utoipa::path(
117 delete,
118 path = "/repos/{space_name}/{repo_name}/tag-protection/{policy_id}/whitelists/{user_id}",
119 tag = "tag_protection",
120 params(TagWhitelistPath),
121 responses(
122 (status = 200, description = "remove tag whitelist", body = AppResponseData<export::rest::NoData>)
123 )
124)]
125pub async fn remove_tag_whitelist(
126 State(service): State<GitService>,
127 Path(p): Path<TagWhitelistPath>,
128) -> Result<AppResponseData<NoData>, ApiError> {
129 service.remove_tag_whitelist(p.policy_id, p.user_id).await.map_err(GitApiError::from)?;
130 Ok(AppResponseData::empty())
131}
132
133#[utoipa::path(
134 get,
135 path = "/repos/{space_name}/{repo_name}/tag-protection/{policy_id}/whitelists",
136 tag = "tag_protection",
137 params(TagPolicyPath),
138 responses(
139 (status = 200, description = "list tag whitelists", body = AppResponseData<Vec<TagWhitelistDto>>)
140 )
141)]
142pub async fn list_tag_whitelists(
143 State(service): State<GitService>,
144 Path(p): Path<TagPolicyPath>,
145) -> Result<AppResponseData<Vec<TagWhitelistDto>>, ApiError> {
146 let out = service.list_tag_whitelists(p.policy_id).await.map_err(GitApiError::from)?;
147 Ok(AppResponseData::ok(out))
148}
149
150#[allow(dead_code)]
151fn _typecheck(_: AppRequestData<AddBranchWhitelistCmd>, _: AppRequestData<AddTagWhitelistCmd>) {}
No newline at end of file
Added lib/git/src/service/licenses.rs View lib/git/src/service/licenses.rs
@@ -0,0 +1,111 @@
1use models::repo::repo_license;
2use sea_orm::{
3 ActiveModelTrait, EntityTrait, QueryOrder, QuerySelect, Set,
4};
5use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
7use utoipa::ToSchema;
8
9use crate::service::GitService;
10use crate::share::errors::GitError;
11
12#[derive(Debug, Clone, Serialize, ToSchema)]
13pub struct LicenseDto {
14 pub name: String,
15 pub display_name: String,
16 pub description: Option<String>,
17 pub body: String,
18 pub url: Option<String>,
19 pub created_at: OffsetDateTime,
20 pub updated_at: OffsetDateTime,
21}
22
23impl From<repo_license::Model> for LicenseDto {
24 fn from(m: repo_license::Model) -> Self {
25 Self {
26 name: m.name,
27 display_name: m.display_name,
28 description: m.description,
29 body: m.body,
30 url: m.url,
31 created_at: m.created_at,
32 updated_at: m.updated_at,
33 }
34 }
35}
36
37#[derive(Debug, Clone, Deserialize, ToSchema)]
38pub struct UpsertLicenseCmd {
39 pub display_name: String,
40 pub description: Option<String>,
41 pub body: String,
42 pub url: Option<String>,
43}
44
45impl GitService {
46 pub async fn get_license(&self, name: &str) -> Result<LicenseDto, GitError> {
47 let row = repo_license::Entity::find_by_id(name.to_string())
48 .one(self.db_read())
49 .await?
50 .ok_or(GitError::NotFound)?;
51 Ok(row.into())
52 }
53
54 pub async fn list_licenses(&self, limit: u64, offset: u64) -> Result<Vec<LicenseDto>, GitError> {
55 let rows = repo_license::Entity::find()
56 .order_by_asc(repo_license::Column::Name)
57 .limit(limit)
58 .offset(offset)
59 .all(self.db_read())
60 .await?;
61 Ok(rows.into_iter().map(Into::into).collect())
62 }
63
64 pub async fn upsert_license(
65 &self,
66 name: &str,
67 cmd: UpsertLicenseCmd,
68 ) -> Result<LicenseDto, GitError> {
69 if name.is_empty() {
70 return Err(GitError::InvalidArgument("license name must not be empty".to_string()));
71 }
72 if cmd.display_name.is_empty() {
73 return Err(GitError::InvalidArgument("license display_name must not be empty".to_string()));
74 }
75 if cmd.body.is_empty() {
76 return Err(GitError::InvalidArgument("license body must not be empty".to_string()));
77 }
78 let now = OffsetDateTime::now_utc();
79 let existing = repo_license::Entity::find_by_id(name.to_string())
80 .one(self.db_read())
81 .await?;
82 let saved = if let Some(row) = existing {
83 let mut am: repo_license::ActiveModel = row.into();
84 am.display_name = Set(cmd.display_name);
85 am.description = Set(cmd.description);
86 am.body = Set(cmd.body);
87 am.url = Set(cmd.url);
88 am.updated_at = Set(now);
89 am.update(self.db_write()).await?
90 } else {
91 let am = repo_license::ActiveModel {
92 name: Set(name.to_string()),
93 display_name: Set(cmd.display_name),
94 description: Set(cmd.description),
95 body: Set(cmd.body),
96 url: Set(cmd.url),
97 created_at: Set(now),
98 updated_at: Set(now),
99 };
100 am.insert(self.db_write()).await?
101 };
102 Ok(saved.into())
103 }
104
105 pub async fn license_exists(&self, name: &str) -> Result<bool, GitError> {
106 let row = repo_license::Entity::find_by_id(name.to_string())
107 .one(self.db_read())
108 .await?;
109 Ok(row.is_some())
110 }
111}
No newline at end of file
Modified lib/git/src/service/mod.rs View lib/git/src/service/mod.rs
@@ -42,6 +42,7 @@
4242pub mod diffs;
4343pub mod gitcmd;
4444pub mod hooks;
45pub mod licenses;
4546pub mod lfs;
4647pub mod logs;
4748pub mod maintenance;
@@ -53,6 +54,10 @@
5354pub mod remotes;
5455pub mod repos;
5556pub mod signatures;
57pub mod stars;
5658pub mod submodules;
5759pub mod tags;
60pub mod topics;
5861pub mod trees;
62pub mod watches;
63pub mod whitelists;
Modified lib/git/src/service/repos.rs View lib/git/src/service/repos.rs
@@ -6,6 +6,7 @@
66use gitbaby::setup::types::{FastImportCommit, ObjectFormat};
77use models::dir::dirs::{self, DirKind};
88use models::repo::repo_collaborations;
9use models::repo::repo_license;
910use models::repo::repos;
1011use models::space::spaces;
1112use sea_orm::{
@@ -162,6 +163,18 @@
162163 }
163164 }
164165
166 if let Some(name) = cmd.license_name.as_deref() {
167 let exists = repo_license::Entity::find_by_id(name.to_string())
168 .one(self.db_read())
169 .await?
170 .is_some();
171 if !exists {
172 return Err(GitError::InvalidArgument(format!(
173 "license `{name}` is not registered"
174 )));
175 }
176 }
177
165178 let (dir, repo) = {
166179 let txn = self.db_write().begin().await?;
167180 let position = {
Added lib/git/src/service/stars.rs View lib/git/src/service/stars.rs
@@ -0,0 +1,159 @@
1use models::repo::repos;
2use models::repo::repo_star;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
5 TransactionTrait, Unchanged,
6};
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use uuid::Uuid;
10use utoipa::ToSchema;
11
12use crate::context::facade::GitContext;
13use crate::service::GitService;
14use crate::share::errors::GitError;
15
16#[derive(Debug, Clone, Serialize, ToSchema)]
17pub struct RepoStarDto {
18 pub user_id: Uuid,
19 pub repo_id: Uuid,
20 pub created_at: OffsetDateTime,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
24pub struct RepoStarResp {
25 pub stars: i32,
26}
27
28#[derive(Debug, Clone, Serialize, ToSchema)]
29pub struct StargazerDto {
30 pub user_id: Uuid,
31 pub username: String,
32 pub display_name: String,
33 pub avatar_url: String,
34 pub starred_at: OffsetDateTime,
35}
36
37impl GitService {
38 pub async fn star_repo(
39 &self,
40 ctx: &GitContext,
41 user_id: Uuid,
42 ) -> Result<RepoStarResp, GitError> {
43 let db = self.db_write();
44 let repo_id = ctx.model.id;
45 let now = OffsetDateTime::now_utc();
46 let existing = repo_star::Entity::find()
47 .filter(repo_star::Column::UserId.eq(user_id))
48 .filter(repo_star::Column::RepoId.eq(repo_id))
49 .one(db)
50 .await?;
51 if existing.is_none() {
52 let row = repo_star::ActiveModel {
53 id: Set(Uuid::now_v7()),
54 user_id: Set(user_id),
55 repo_id: Set(repo_id),
56 created_at: Set(now),
57 updated_at: Set(now),
58 };
59 row.insert(db).await?;
60 }
61 let after = repo_star::Entity::find()
62 .filter(repo_star::Column::RepoId.eq(repo_id))
63 .all(db)
64 .await?
65 .len() as i32;
66 let am = repos::ActiveModel {
67 id: Unchanged(repo_id),
68 nums_star: Set(after),
69 updated_at: Set(now),
70 ..Default::default()
71 };
72 am.update(db).await?;
73 Ok(RepoStarResp { stars: after })
74 }
75
76 pub async fn unstar_repo(
77 &self,
78 ctx: &GitContext,
79 user_id: Uuid,
80 ) -> Result<RepoStarResp, GitError> {
81 let db = self.db_write();
82 let repo_id = ctx.model.id;
83 let now = OffsetDateTime::now_utc();
84 TransactionTrait::transaction(db, |txn| {
85 Box::pin(async move {
86 repo_star::Entity::delete_many()
87 .filter(repo_star::Column::UserId.eq(user_id))
88 .filter(repo_star::Column::RepoId.eq(repo_id))
89 .exec(txn)
90 .await?;
91 Ok::<(), sea_orm::DbErr>(())
92 })
93 })
94 .await?;
95 let after = repo_star::Entity::find()
96 .filter(repo_star::Column::RepoId.eq(repo_id))
97 .all(db)
98 .await?
99 .len() as i32;
100 let am = repos::ActiveModel {
101 id: Unchanged(repo_id),
102 nums_star: Set(after),
103 updated_at: Set(now),
104 ..Default::default()
105 };
106 am.update(db).await?;
107 Ok(RepoStarResp { stars: after })
108 }
109
110 pub async fn is_starred(
111 &self,
112 ctx: &GitContext,
113 user_id: Uuid,
114 ) -> Result<bool, GitError> {
115 let row = repo_star::Entity::find()
116 .filter(repo_star::Column::UserId.eq(user_id))
117 .filter(repo_star::Column::RepoId.eq(ctx.model.id))
118 .one(self.db_read())
119 .await?;
120 Ok(row.is_some())
121 }
122
123 pub async fn list_stargazers(
124 &self,
125 ctx: &GitContext,
126 limit: u64,
127 offset: u64,
128 ) -> Result<Vec<StargazerDto>, GitError> {
129 let rows = repo_star::Entity::find()
130 .filter(repo_star::Column::RepoId.eq(ctx.model.id))
131 .order_by_desc(repo_star::Column::CreatedAt)
132 .limit(limit)
133 .offset(offset)
134 .all(self.db_read())
135 .await?;
136 if rows.is_empty() {
137 return Ok(Vec::new());
138 }
139 let user_ids: Vec<Uuid> = rows.iter().map(|r| r.user_id).collect();
140 let users = models::users::user::Entity::find()
141 .filter(models::users::user::Column::Uid.is_in(user_ids.iter().copied()))
142 .all(self.db_read())
143 .await?;
144 let by_id: std::collections::HashMap<Uuid, models::users::user::Model> =
145 users.into_iter().map(|u| (u.uid, u)).collect();
146 Ok(rows
147 .into_iter()
148 .filter_map(|row| {
149 by_id.get(&row.user_id).map(|u| StargazerDto {
150 user_id: u.uid,
151 username: u.username.clone(),
152 display_name: u.display_name.clone(),
153 avatar_url: u.avatar_url.clone(),
154 starred_at: row.created_at,
155 })
156 })
157 .collect())
158 }
159}
No newline at end of file
Added lib/git/src/service/topics.rs View lib/git/src/service/topics.rs
@@ -0,0 +1,202 @@
1use models::repo::repo_relation_topic;
2use models::repo::repo_topic;
3use models::repo::repos;
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
6};
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use uuid::Uuid;
10use utoipa::ToSchema;
11
12use crate::context::facade::GitContext;
13use crate::service::GitService;
14use crate::share::errors::GitError;
15
16#[derive(Debug, Clone, Serialize, ToSchema)]
17pub struct TopicDto {
18 pub id: Uuid,
19 pub name: String,
20 pub created_at: OffsetDateTime,
21 pub updated_at: OffsetDateTime,
22}
23
24impl From<repo_topic::Model> for TopicDto {
25 fn from(m: repo_topic::Model) -> Self {
26 Self {
27 id: m.id,
28 name: m.name,
29 created_at: m.created_at,
30 updated_at: m.updated_at,
31 }
32 }
33}
34
35#[derive(Debug, Clone, Deserialize, ToSchema)]
36pub struct CreateTopicCmd {
37 pub name: String,
38}
39
40#[derive(Debug, Clone, Serialize, ToSchema)]
41pub struct TopicRepoDto {
42 pub repo_id: Uuid,
43 pub repo_name: String,
44 pub space_name: String,
45 pub is_private: bool,
46}
47
48impl GitService {
49 pub async fn create_topic(&self, name: String) -> Result<TopicDto, GitError> {
50 let trimmed = name.trim().to_string();
51 if trimmed.is_empty() {
52 return Err(GitError::InvalidArgument("topic name must not be empty".to_string()));
53 }
54 let now = OffsetDateTime::now_utc();
55 let existing = repo_topic::Entity::find()
56 .filter(repo_topic::Column::Name.eq(&trimmed))
57 .one(self.db_read())
58 .await?;
59 if let Some(row) = existing {
60 return Ok(row.into());
61 }
62 let am = repo_topic::ActiveModel {
63 id: Set(Uuid::now_v7()),
64 name: Set(trimmed),
65 created_at: Set(now),
66 updated_at: Set(now),
67 };
68 let saved = am.insert(self.db_write()).await?;
69 Ok(saved.into())
70 }
71
72 pub async fn get_topic(&self, name: &str) -> Result<TopicDto, GitError> {
73 let row = repo_topic::Entity::find()
74 .filter(repo_topic::Column::Name.eq(name))
75 .one(self.db_read())
76 .await?
77 .ok_or(GitError::NotFound)?;
78 Ok(row.into())
79 }
80
81 pub async fn list_topics(&self, limit: u64, offset: u64) -> Result<Vec<TopicDto>, GitError> {
82 let rows = repo_topic::Entity::find()
83 .order_by_asc(repo_topic::Column::Name)
84 .limit(limit)
85 .offset(offset)
86 .all(self.db_read())
87 .await?;
88 Ok(rows.into_iter().map(Into::into).collect())
89 }
90
91 pub async fn add_topic_to_repo(
92 &self,
93 ctx: &GitContext,
94 topic_name: &str,
95 ) -> Result<TopicDto, GitError> {
96 let now = OffsetDateTime::now_utc();
97 let topic = match repo_topic::Entity::find()
98 .filter(repo_topic::Column::Name.eq(topic_name))
99 .one(self.db_read())
100 .await?
101 {
102 Some(t) => t,
103 None => {
104 let am = repo_topic::ActiveModel {
105 id: Set(Uuid::now_v7()),
106 name: Set(topic_name.to_string()),
107 created_at: Set(now),
108 updated_at: Set(now),
109 };
110 am.insert(self.db_write()).await?
111 }
112 };
113 let existing = repo_relation_topic::Entity::find()
114 .filter(repo_relation_topic::Column::RepoId.eq(ctx.model.id))
115 .filter(repo_relation_topic::Column::TopicId.eq(topic.id))
116 .one(self.db_read())
117 .await?;
118 if existing.is_none() {
119 let am = repo_relation_topic::ActiveModel {
120 id: Set(Uuid::now_v7()),
121 repo_id: Set(ctx.model.id),
122 topic_id: Set(topic.id),
123 created_at: Set(now),
124 };
125 am.insert(self.db_write()).await?;
126 }
127 Ok(topic.into())
128 }
129
130 pub async fn remove_topic_from_repo(
131 &self,
132 ctx: &GitContext,
133 topic_name: &str,
134 ) -> Result<(), GitError> {
135 let topic = repo_topic::Entity::find()
136 .filter(repo_topic::Column::Name.eq(topic_name))
137 .one(self.db_read())
138 .await?
139 .ok_or(GitError::NotFound)?;
140 repo_relation_topic::Entity::delete_many()
141 .filter(repo_relation_topic::Column::RepoId.eq(ctx.model.id))
142 .filter(repo_relation_topic::Column::TopicId.eq(topic.id))
143 .exec(self.db_write())
144 .await?;
145 Ok(())
146 }
147
148 pub async fn list_repo_topics(
149 &self,
150 ctx: &GitContext,
151 ) -> Result<Vec<TopicDto>, GitError> {
152 let relations = repo_relation_topic::Entity::find()
153 .filter(repo_relation_topic::Column::RepoId.eq(ctx.model.id))
154 .all(self.db_read())
155 .await?;
156 if relations.is_empty() {
157 return Ok(Vec::new());
158 }
159 let topic_ids: Vec<Uuid> = relations.iter().map(|r| r.topic_id).collect();
160 let topics = repo_topic::Entity::find()
161 .filter(repo_topic::Column::Id.is_in(topic_ids))
162 .all(self.db_read())
163 .await?;
164 Ok(topics.into_iter().map(Into::into).collect())
165 }
166
167 pub async fn list_repos_by_topic(
168 &self,
169 topic_name: &str,
170 limit: u64,
171 offset: u64,
172 ) -> Result<Vec<TopicRepoDto>, GitError> {
173 let topic = repo_topic::Entity::find()
174 .filter(repo_topic::Column::Name.eq(topic_name))
175 .one(self.db_read())
176 .await?
177 .ok_or(GitError::NotFound)?;
178 let relations = repo_relation_topic::Entity::find()
179 .filter(repo_relation_topic::Column::TopicId.eq(topic.id))
180 .limit(limit)
181 .offset(offset)
182 .all(self.db_read())
183 .await?;
184 if relations.is_empty() {
185 return Ok(Vec::new());
186 }
187 let repo_ids: Vec<Uuid> = relations.iter().map(|r| r.repo_id).collect();
188 let rows = repos::Entity::find()
189 .filter(repos::Column::Id.is_in(repo_ids))
190 .all(self.db_read())
191 .await?;
192 Ok(rows
193 .into_iter()
194 .map(|r| TopicRepoDto {
195 repo_id: r.id,
196 repo_name: r.name,
197 space_name: String::new(),
198 is_private: r.is_private,
199 })
200 .collect())
201 }
202}
No newline at end of file
Added lib/git/src/service/watches.rs View lib/git/src/service/watches.rs
@@ -0,0 +1,186 @@
1use models::common::watch_mode::WatchMode;
2use models::repo::repos;
3use models::repo::repo_watch;
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
6 TransactionTrait, Unchanged,
7};
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10use uuid::Uuid;
11use utoipa::ToSchema;
12
13use crate::context::facade::GitContext;
14use crate::service::GitService;
15use crate::share::errors::GitError;
16
17#[derive(Debug, Clone, Serialize, ToSchema)]
18pub struct RepoWatchDto {
19 pub user_id: Uuid,
20 pub repo_id: Uuid,
21 pub mode: WatchMode,
22 pub created_at: OffsetDateTime,
23 pub updated_at: OffsetDateTime,
24}
25
26#[derive(Debug, Clone, Deserialize, ToSchema)]
27pub struct UpsertWatchCmd {
28 pub mode: WatchMode,
29}
30
31#[derive(Debug, Clone, Serialize, ToSchema)]
32pub struct WatcherDto {
33 pub user_id: Uuid,
34 pub username: String,
35 pub display_name: String,
36 pub avatar_url: String,
37 pub mode: WatchMode,
38 pub watching_since: OffsetDateTime,
39}
40
41impl GitService {
42 pub async fn upsert_watch(
43 &self,
44 ctx: &GitContext,
45 user_id: Uuid,
46 cmd: UpsertWatchCmd,
47 ) -> Result<RepoWatchDto, GitError> {
48 let db = self.db_write();
49 let repo_id = ctx.model.id;
50 let now = OffsetDateTime::now_utc();
51 let dto = TransactionTrait::transaction(db, |txn| {
52 Box::pin(async move {
53 let existing = repo_watch::Entity::find()
54 .filter(repo_watch::Column::UserId.eq(user_id))
55 .filter(repo_watch::Column::RepoId.eq(repo_id))
56 .one(txn)
57 .await?;
58 let saved = if let Some(row) = existing {
59 let mut am: repo_watch::ActiveModel = row.into();
60 am.mode = Set(cmd.mode);
61 am.updated_at = Set(now);
62 am.update(txn).await?
63 } else {
64 let am = repo_watch::ActiveModel {
65 id: Set(Uuid::now_v7()),
66 user_id: Set(user_id),
67 repo_id: Set(repo_id),
68 mode: Set(cmd.mode),
69 created_at: Set(now),
70 updated_at: Set(now),
71 };
72 am.insert(txn).await?
73 };
74 Ok::<repo_watch::Model, sea_orm::DbErr>(saved)
75 })
76 })
77 .await?;
78 let after = repo_watch::Entity::find()
79 .filter(repo_watch::Column::RepoId.eq(repo_id))
80 .all(db)
81 .await?
82 .len() as i32;
83 let am = repos::ActiveModel {
84 id: Unchanged(repo_id),
85 nums_watch: Set(after),
86 updated_at: Set(now),
87 ..Default::default()
88 };
89 am.update(db).await?;
90 Ok(RepoWatchDto {
91 user_id: dto.user_id,
92 repo_id: dto.repo_id,
93 mode: dto.mode,
94 created_at: dto.created_at,
95 updated_at: dto.updated_at,
96 })
97 }
98
99 pub async fn unwatch(
100 &self,
101 ctx: &GitContext,
102 user_id: Uuid,
103 ) -> Result<(), GitError> {
104 let db = self.db_write();
105 let repo_id = ctx.model.id;
106 let now = OffsetDateTime::now_utc();
107 TransactionTrait::transaction(db, |txn| {
108 Box::pin(async move {
109 repo_watch::Entity::delete_many()
110 .filter(repo_watch::Column::UserId.eq(user_id))
111 .filter(repo_watch::Column::RepoId.eq(repo_id))
112 .exec(txn)
113 .await?;
114 Ok::<(), sea_orm::DbErr>(())
115 })
116 })
117 .await?;
118 let after = repo_watch::Entity::find()
119 .filter(repo_watch::Column::RepoId.eq(repo_id))
120 .all(db)
121 .await?
122 .len() as i32;
123 let am = repos::ActiveModel {
124 id: Unchanged(repo_id),
125 nums_watch: Set(after),
126 updated_at: Set(now),
127 ..Default::default()
128 };
129 am.update(db).await?;
130 Ok(())
131 }
132
133 pub async fn get_watch_mode(
134 &self,
135 ctx: &GitContext,
136 user_id: Uuid,
137 ) -> Result<Option<WatchMode>, GitError> {
138 let row = repo_watch::Entity::find()
139 .filter(repo_watch::Column::UserId.eq(user_id))
140 .filter(repo_watch::Column::RepoId.eq(ctx.model.id))
141 .one(self.db_read())
142 .await?;
143 Ok(row.map(|r| r.mode))
144 }
145
146 pub async fn list_watchers(
147 &self,
148 ctx: &GitContext,
149 mode: Option<WatchMode>,
150 limit: u64,
151 offset: u64,
152 ) -> Result<Vec<WatcherDto>, GitError> {
153 let mut q = repo_watch::Entity::find()
154 .filter(repo_watch::Column::RepoId.eq(ctx.model.id))
155 .order_by_desc(repo_watch::Column::CreatedAt)
156 .limit(limit)
157 .offset(offset);
158 if let Some(m) = mode {
159 q = q.filter(repo_watch::Column::Mode.eq(m));
160 }
161 let rows = q.all(self.db_read()).await?;
162 if rows.is_empty() {
163 return Ok(Vec::new());
164 }
165 let user_ids: Vec<Uuid> = rows.iter().map(|r| r.user_id).collect();
166 let users = models::users::user::Entity::find()
167 .filter(models::users::user::Column::Uid.is_in(user_ids.iter().copied()))
168 .all(self.db_read())
169 .await?;
170 let by_id: std::collections::HashMap<Uuid, models::users::user::Model> =
171 users.into_iter().map(|u| (u.uid, u)).collect();
172 Ok(rows
173 .into_iter()
174 .filter_map(|row| {
175 by_id.get(&row.user_id).map(|u| WatcherDto {
176 user_id: u.uid,
177 username: u.username.clone(),
178 display_name: u.display_name.clone(),
179 avatar_url: u.avatar_url.clone(),
180 mode: row.mode,
181 watching_since: row.created_at,
182 })
183 })
184 .collect())
185 }
186}
No newline at end of file
Added lib/git/src/service/whitelists.rs View lib/git/src/service/whitelists.rs
@@ -0,0 +1,184 @@
1use models::repo::repo_branch_protection_policy;
2use models::repo::repo_branch_protection_whitelists::{self, WhitelistKind};
3use models::repo::repo_tag_protection_policy;
4use models::repo::repo_tag_protection_whitelists;
5use sea_orm::{
6 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait,
7};
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10use uuid::Uuid;
11use utoipa::ToSchema;
12
13use crate::service::GitService;
14use crate::share::errors::GitError;
15
16#[derive(Debug, Clone, Serialize, ToSchema)]
17pub struct BranchWhitelistDto {
18 pub id: Uuid,
19 pub policy_id: Uuid,
20 pub kind: WhitelistKind,
21 pub user_id: Option<Uuid>,
22 pub group_id: Option<Uuid>,
23 pub created_at: OffsetDateTime,
24}
25
26impl From<repo_branch_protection_whitelists::Model> for BranchWhitelistDto {
27 fn from(m: repo_branch_protection_whitelists::Model) -> Self {
28 Self {
29 id: m.id,
30 policy_id: m.policy_id,
31 kind: m.kind,
32 user_id: m.user_id,
33 group_id: m.group_id,
34 created_at: OffsetDateTime::now_utc(),
35 }
36 }
37}
38
39#[derive(Debug, Clone, Deserialize, ToSchema)]
40pub struct AddBranchWhitelistCmd {
41 pub kind: WhitelistKind,
42 pub user_id: Option<Uuid>,
43 pub group_id: Option<Uuid>,
44}
45
46#[derive(Debug, Clone, Serialize, ToSchema)]
47pub struct TagWhitelistDto {
48 pub policy_id: Uuid,
49 pub user_id: Uuid,
50}
51
52impl From<repo_tag_protection_whitelists::Model> for TagWhitelistDto {
53 fn from(m: repo_tag_protection_whitelists::Model) -> Self {
54 Self {
55 policy_id: m.policy_id,
56 user_id: m.user_id,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Deserialize, ToSchema)]
62pub struct AddTagWhitelistCmd {
63 pub user_id: Uuid,
64}
65
66impl GitService {
67 async fn ensure_branch_policy(&self, policy_id: Uuid) -> Result<repo_branch_protection_policy::Model, GitError> {
68 repo_branch_protection_policy::Entity::find_by_id(policy_id)
69 .one(self.db_read())
70 .await?
71 .ok_or(GitError::NotFound)
72 }
73
74 async fn ensure_tag_policy(&self, policy_id: Uuid) -> Result<repo_tag_protection_policy::Model, GitError> {
75 repo_tag_protection_policy::Entity::find_by_id(policy_id)
76 .one(self.db_read())
77 .await?
78 .ok_or(GitError::NotFound)
79 }
80
81 pub async fn add_branch_whitelist(
82 &self,
83 policy_id: Uuid,
84 cmd: AddBranchWhitelistCmd,
85 ) -> Result<BranchWhitelistDto, GitError> {
86 let _ = self.ensure_branch_policy(policy_id).await?;
87 if cmd.user_id.is_some() == cmd.group_id.is_some() {
88 return Err(GitError::InvalidArgument(
89 "exactly one of user_id or group_id must be set".to_string(),
90 ));
91 }
92 let existing = repo_branch_protection_whitelists::Entity::find()
93 .filter(repo_branch_protection_whitelists::Column::PolicyId.eq(policy_id))
94 .filter(repo_branch_protection_whitelists::Column::Kind.eq(cmd.kind))
95 .filter(repo_branch_protection_whitelists::Column::UserId.eq(cmd.user_id))
96 .filter(repo_branch_protection_whitelists::Column::GroupId.eq(cmd.group_id))
97 .one(self.db_read())
98 .await?;
99 if let Some(row) = existing {
100 return Ok(row.into());
101 }
102 let am = repo_branch_protection_whitelists::ActiveModel {
103 id: Set(Uuid::now_v7()),
104 policy_id: Set(policy_id),
105 kind: Set(cmd.kind),
106 user_id: Set(cmd.user_id),
107 group_id: Set(cmd.group_id),
108 };
109 let saved = am.insert(self.db_write()).await?;
110 Ok(saved.into())
111 }
112
113 pub async fn remove_branch_whitelist(
114 &self,
115 policy_id: Uuid,
116 whitelist_id: Uuid,
117 ) -> Result<(), GitError> {
118 repo_branch_protection_whitelists::Entity::delete_by_id(whitelist_id)
119 .filter(repo_branch_protection_whitelists::Column::PolicyId.eq(policy_id))
120 .exec(self.db_write())
121 .await?;
122 Ok(())
123 }
124
125 pub async fn list_branch_whitelists(
126 &self,
127 policy_id: Uuid,
128 ) -> Result<Vec<BranchWhitelistDto>, GitError> {
129 let _ = self.ensure_branch_policy(policy_id).await?;
130 let rows = repo_branch_protection_whitelists::Entity::find()
131 .filter(repo_branch_protection_whitelists::Column::PolicyId.eq(policy_id))
132 .all(self.db_read())
133 .await?;
134 Ok(rows.into_iter().map(Into::into).collect())
135 }
136
137 pub async fn add_tag_whitelist(
138 &self,
139 policy_id: Uuid,
140 cmd: AddTagWhitelistCmd,
141 ) -> Result<TagWhitelistDto, GitError> {
142 let _ = self.ensure_tag_policy(policy_id).await?;
143 let existing = repo_tag_protection_whitelists::Entity::find_by_id((policy_id, cmd.user_id))
144 .one(self.db_read())
145 .await?;
146 if let Some(row) = existing {
147 return Ok(row.into());
148 }
149 let am = repo_tag_protection_whitelists::ActiveModel {
150 policy_id: Set(policy_id),
151 user_id: Set(cmd.user_id),
152 };
153 let saved = TransactionTrait::transaction(self.db_write(), |txn| {
154 Box::pin(async move {
155 am.insert(txn).await.map_err(GitError::from)
156 })
157 })
158 .await?;
159 Ok(saved.into())
160 }
161
162 pub async fn remove_tag_whitelist(
163 &self,
164 policy_id: Uuid,
165 user_id: Uuid,
166 ) -> Result<(), GitError> {
167 repo_tag_protection_whitelists::Entity::delete_by_id((policy_id, user_id))
168 .exec(self.db_write())
169 .await?;
170 Ok(())
171 }
172
173 pub async fn list_tag_whitelists(
174 &self,
175 policy_id: Uuid,
176 ) -> Result<Vec<TagWhitelistDto>, GitError> {
177 let _ = self.ensure_tag_policy(policy_id).await?;
178 let rows = repo_tag_protection_whitelists::Entity::find()
179 .filter(repo_tag_protection_whitelists::Column::PolicyId.eq(policy_id))
180 .all(self.db_read())
181 .await?;
182 Ok(rows.into_iter().map(Into::into).collect())
183 }
184}
No newline at end of file
Modified lib/git/src/share/policy.rs View lib/git/src/share/policy.rs
@@ -1,6 +1,8 @@
11use globset::{Glob, GlobMatcher};
22use models::repo::repo_branch_protection_policy;
3use models::repo::repo_branch_protection_whitelists;
34use models::repo::repo_tag_protection_policy;
5use models::repo::repo_tag_protection_whitelists;
46use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
57use uuid::Uuid;
68
@@ -53,3 +55,74 @@
5355 .await?;
5456 BranchPolicy::from_patterns(rows.into_iter().map(|r| r.name_pattern))
5557}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum WhitelistSubject {
61 User(Uuid),
62 #[allow(dead_code)]
63 Group(Uuid),
64}
65
66#[derive(Debug, Default)]
67pub struct WhitelistSet {
68 pub push_allowed: Vec<Uuid>,
69 pub merge_allowed: Vec<Uuid>,
70 pub approval_allowed: Vec<Uuid>,
71}
72
73impl WhitelistSet {
74 pub fn push_actor_allowed(&self, subject: WhitelistSubject) -> bool {
75 match subject {
76 WhitelistSubject::User(uid) => self.push_allowed.iter().any(|entry| *entry == uid),
77 WhitelistSubject::Group(_) => false,
78 }
79 }
80
81 pub fn tag_modifier_allowed(&self, uid: Uuid) -> bool {
82 self.merge_allowed.iter().any(|entry| *entry == uid)
83 || self.approval_allowed.iter().any(|entry| *entry == uid)
84 }
85}
86
87pub async fn load_branch_whitelists(
88 db: &sea_orm::DatabaseConnection,
89 policy_ids: &[Uuid],
90) -> Result<WhitelistSet, GitError> {
91 if policy_ids.is_empty() {
92 return Ok(WhitelistSet::default());
93 }
94 let rows = repo_branch_protection_whitelists::Entity::find()
95 .filter(repo_branch_protection_whitelists::Column::PolicyId.is_in(policy_ids.iter().copied()))
96 .all(db)
97 .await?;
98 let mut out = WhitelistSet::default();
99 for row in rows {
100 let Some(uid) = row.user_id else { continue };
101 match row.kind {
102 repo_branch_protection_whitelists::WhitelistKind::Push => out.push_allowed.push(uid),
103 repo_branch_protection_whitelists::WhitelistKind::Merge => out.merge_allowed.push(uid),
104 repo_branch_protection_whitelists::WhitelistKind::Approval => {
105 out.approval_allowed.push(uid)
106 }
107 }
108 }
109 Ok(out)
110}
111
112pub async fn load_tag_whitelists(
113 db: &sea_orm::DatabaseConnection,
114 policy_ids: &[Uuid],
115) -> Result<WhitelistSet, GitError> {
116 if policy_ids.is_empty() {
117 return Ok(WhitelistSet::default());
118 }
119 let rows = repo_tag_protection_whitelists::Entity::find()
120 .filter(repo_tag_protection_whitelists::Column::PolicyId.is_in(policy_ids.iter().copied()))
121 .all(db)
122 .await?;
123 let mut out = WhitelistSet::default();
124 for row in rows {
125 out.merge_allowed.push(row.user_id);
126 }
127 Ok(out)
128}
No newline at end of file
Modified lib/git/src/smart/pre_receive.rs View lib/git/src/smart/pre_receive.rs
@@ -3,6 +3,9 @@
33use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
44use uuid::Uuid;
55
6use crate::share::policy::{
7 load_branch_whitelists, load_tag_whitelists, WhitelistSet, WhitelistSubject,
8};
69use crate::smart::error::{SmartError, SmartResult};
710use crate::smart::pktline;
811
@@ -68,6 +71,15 @@
6871 Some(models::repo::repo_collaborations::CollaborationMode::Admin)
6972 );
7073
74 let branch_policy_ids: Vec<Uuid> = branch_policies.iter().map(|p| p.id).collect();
75 let branch_whitelists = load_branch_whitelists(db, &branch_policy_ids)
76 .await
77 .map_err(|e| SmartError::Internal(format!("load branch whitelists: {e}")))?;
78 let tag_policy_ids: Vec<Uuid> = tag_policies.iter().map(|p| p.id).collect();
79 let tag_whitelists = load_tag_whitelists(db, &tag_policy_ids)
80 .await
81 .map_err(|e| SmartError::Internal(format!("load tag whitelists: {e}")))?;
82
7183 let oid_len = oid_len_of(refs);
7284 let zero_oid = "0".repeat(oid_len);
7385
@@ -80,6 +92,8 @@
8092 &tag_policies,
8193 actor_user_id,
8294 actor_is_admin,
95 &branch_whitelists,
96 &tag_whitelists,
8397 &zero_oid,
8498 )
8599 .await?;
@@ -95,13 +109,31 @@
95109 tag_policies: &[models::repo::repo_tag_protection_policy::Model],
96110 actor_user_id: Uuid,
97111 actor_is_admin: bool,
112 branch_whitelists: &WhitelistSet,
113 tag_whitelists: &WhitelistSet,
98114 zero_oid: &str,
99115) -> SmartResult<RefVerdict> {
100116 if let Some(branch) = r.refname.strip_prefix("refs/heads/") {
101 return judge_branch(baby, branch, r, branch_policies, actor_is_admin, zero_oid).await;
117 return judge_branch(
118 baby,
119 branch,
120 r,
121 branch_policies,
122 actor_user_id,
123 actor_is_admin,
124 branch_whitelists,
125 zero_oid,
126 )
127 .await;
102128 }
103129 if let Some(tag) = r.refname.strip_prefix("refs/tags/") {
104 return Ok(judge_tag(tag, tag_policies, actor_user_id));
130 return Ok(judge_tag(
131 tag,
132 tag_policies,
133 actor_user_id,
134 actor_is_admin,
135 tag_whitelists,
136 ));
105137 }
106138 Ok(RefVerdict::Accept)
107139}
@@ -111,7 +143,9 @@
111143 branch: &str,
112144 r: &RefUpdate,
113145 branch_policies: &[models::repo::repo_branch_protection_policy::Model],
146 actor_user_id: Uuid,
114147 actor_is_admin: bool,
148 whitelists: &WhitelistSet,
115149 zero_oid: &str,
116150) -> SmartResult<RefVerdict> {
117151 let matchers: Vec<(&models::repo::repo_branch_protection_policy::Model, GlobMatcher)> =
@@ -139,12 +173,20 @@
139173 }
140174
141175 if !policy.enable_push {
176 if policy.enable_push_whitelist
177 && whitelists.push_actor_allowed(WhitelistSubject::User(actor_user_id))
178 {
179 return Ok(RefVerdict::Accept);
180 }
142181 return Ok(RefVerdict::Reject(format!(
143182 "pushes to protected branch `{branch}` are not allowed"
144183 )));
145184 }
146185
147186 if is_force && !policy.enable_force_push && !actor_is_admin {
187 if whitelists.push_actor_allowed(WhitelistSubject::User(actor_user_id)) {
188 return Ok(RefVerdict::Accept);
189 }
148190 return Ok(RefVerdict::Reject(format!(
149191 "force-push to protected branch `{branch}` is not allowed"
150192 )));
@@ -156,7 +198,9 @@
156198fn judge_tag(
157199 tag: &str,
158200 tag_policies: &[models::repo::repo_tag_protection_policy::Model],
159 _actor_user_id: Uuid,
201 actor_user_id: Uuid,
202 actor_is_admin: bool,
203 whitelists: &WhitelistSet,
160204) -> RefVerdict {
161205 for p in tag_policies {
162206 let Ok(glob) = Glob::new(&p.name_pattern) else {
@@ -165,6 +209,9 @@
165209 if !glob.compile_matcher().is_match(tag) {
166210 continue;
167211 }
212 if actor_is_admin || whitelists.tag_modifier_allowed(actor_user_id) {
213 return RefVerdict::Accept;
214 }
168215 return RefVerdict::Reject(format!(
169216 "tag `{tag}` is protected; only the policy owner(s) can modify it"
170217 ));
@@ -184,8 +231,6 @@
184231 return Ok(true);
185232 }
186233 if old.chars().all(|c| c == '0') || new.chars().all(|c| c == '0') {
187 // zero_oid cases (delete / new branch) are handled upstream;
188 // treat as non-ff so the caller still rejects force push.
189234 return Ok(false);
190235 }
191236 baby.is_ancestor(old, new)
Modified lib/git/src/sync/error.rs View lib/git/src/sync/error.rs
@@ -30,6 +30,9 @@
3030 #[error("invalid sync task: {0}")]
3131 InvalidTask(String),
3232
33 #[error("invalid license: {0}")]
34 InvalidLicense(String),
35
3336 #[error("internal error: {0}")]
3437 Internal(String),
3538}
Added lib/git/src/sync/license.rs View lib/git/src/sync/license.rs
@@ -0,0 +1,153 @@
1use std::path::Path;
2
3use models::repo::repo_license;
4use models::repo::repos;
5use sea_orm::{ActiveModelTrait, DatabaseTransaction, EntityTrait, Set};
6
7use crate::sync::error::{SyncError, SyncResult};
8
9const LICENSE_FILES: &[&str] = &["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "COPYING.md"];
10
11pub async fn sync_repo_license(
12 txn: &DatabaseTransaction,
13 repo_dir: &Path,
14 repo: &repos::Model,
15) -> SyncResult<()> {
16 let Some(name) = repo.license_name.clone() else {
17 return Ok(());
18 };
19 let body = read_license_body(repo_dir).await?;
20 let display_name = infer_display_name(&name, body.as_deref());
21 let description = infer_description(&name, body.as_deref());
22 let url = infer_url(&name);
23 let now = time::OffsetDateTime::now_utc();
24 if let Some(existing) = repo_license::Entity::find_by_id(name.clone())
25 .one(txn)
26 .await?
27 {
28 let mut am: repo_license::ActiveModel = existing.into();
29 am.display_name = Set(display_name);
30 am.description = Set(description);
31 if let Some(text) = body.clone() {
32 am.body = Set(text);
33 }
34 am.url = Set(url);
35 am.updated_at = Set(now);
36 am.update(txn).await?;
37 } else {
38 let am = repo_license::ActiveModel {
39 name: Set(name.clone()),
40 display_name: Set(display_name),
41 description: Set(description),
42 body: Set(body.unwrap_or_else(|| format!("License: {name}"))),
43 url: Set(url),
44 created_at: Set(now),
45 updated_at: Set(now),
46 };
47 am.insert(txn).await?;
48 }
49 let verified = repo_license::Entity::find_by_id(name.clone())
50 .one(txn)
51 .await?
52 .ok_or_else(|| {
53 SyncError::Internal(format!(
54 "license `{name}` missing after upsert for repo {}",
55 repo.id
56 ))
57 })?;
58 if verified.name != name {
59 return Err(SyncError::InvalidLicense(format!(
60 "license row mismatch: expected `{name}`, got `{}`",
61 verified.name
62 )));
63 }
64 Ok(())
65}
66
67async fn read_license_body(repo_dir: &Path) -> SyncResult<Option<String>> {
68 for candidate in LICENSE_FILES {
69 let path = repo_dir.join(candidate);
70 match tokio::fs::read(&path).await {
71 Ok(bytes) => {
72 let text = String::from_utf8_lossy(&bytes).to_string();
73 if !text.trim().is_empty() {
74 return Ok(Some(truncate(&text, 64 * 1024)));
75 }
76 }
77 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
78 Err(error) => return Err(SyncError::Io(error)),
79 }
80 }
81 Ok(None)
82}
83
84fn truncate(text: &str, limit: usize) -> String {
85 if text.len() <= limit {
86 return text.to_string();
87 }
88 let mut end = limit;
89 while end > 0 && !text.is_char_boundary(end) {
90 end -= 1;
91 }
92 text[..end].to_string()
93}
94
95fn infer_display_name(name: &str, body: Option<&str>) -> String {
96 if let Some(text) = body {
97 for line in text.lines().take(20) {
98 let trimmed = line.trim();
99 if trimmed.is_empty() {
100 continue;
101 }
102 if trimmed.to_lowercase().contains("license") {
103 let candidate = trimmed
104 .trim_start_matches(|c: char| !c.is_alphanumeric())
105 .trim();
106 if candidate.len() >= 3 && candidate.len() <= 80 {
107 return candidate.to_string();
108 }
109 }
110 }
111 }
112 match name.to_uppercase().as_str() {
113 "MIT" => "MIT License",
114 "APACHE-2.0" | "APACHE2" => "Apache License 2.0",
115 "GPL-3.0" | "GPL3" => "GNU General Public License v3.0",
116 "BSD-3-CLAUSE" => "BSD 3-Clause License",
117 "BSD-2-CLAUSE" => "BSD 2-Clause License",
118 "MPL-2.0" => "Mozilla Public License 2.0",
119 "ISC" => "ISC License",
120 "UNLICENSE" => "The Unlicense",
121 other => other,
122 }
123 .to_string()
124}
125
126fn infer_description(name: &str, body: Option<&str>) -> Option<String> {
127 let needle = name.to_lowercase();
128 if let Some(text) = body {
129 for line in text.lines().take(20) {
130 let lower = line.to_lowercase();
131 if lower.contains(&needle) && lower.contains("license") && line.len() <= 200 {
132 return Some(line.trim().to_string());
133 }
134 }
135 }
136 None
137}
138
139fn infer_url(name: &str) -> Option<String> {
140 let key = name.to_uppercase();
141 let slug = match key.as_str() {
142 "MIT" => "mit",
143 "APACHE-2.0" | "APACHE2" => "apache-2.0",
144 "GPL-3.0" | "GPL3" => "gpl-3.0",
145 "GPL-2.0" | "GPL2" => "gpl-2.0",
146 "BSD-3-CLAUSE" | "BSD-2-CLAUSE" => "bsd",
147 "MPL-2.0" => "mpl-2.0",
148 "ISC" => "isc",
149 "UNLICENSE" => "unlicense",
150 _ => return None,
151 };
152 Some(format!("https://spdx.org/licenses/{slug}.html"))
153}
No newline at end of file
Modified lib/git/src/sync/mod.rs View lib/git/src/sync/mod.rs
@@ -4,6 +4,7 @@
44pub mod event;
55pub mod fsck;
66pub mod languages;
7pub mod license;
78pub mod lock;
89pub mod post_receive;
910pub mod queue;
Modified lib/git/src/sync/sync.rs View lib/git/src/sync/sync.rs
@@ -16,6 +16,7 @@
1616use crate::sync::error::{SyncError, SyncResult};
1717use crate::sync::fsck;
1818use crate::sync::languages;
19use crate::sync::license;
1920use crate::sync::lock::{self, SyncLock};
2021use crate::sync::stats;
2122use crate::sync::tags;
@@ -136,7 +137,7 @@
136137
137138 languages::sync_repo_languages(&baby, &txn, &self.repo, &self.settings).await?;
138139
139 stats::sync_repo_stats(
140stats::sync_repo_stats(
140141 &txn,
141142 &self.repo,
142143 &self.repo_dir(),
@@ -147,6 +148,8 @@
147148 )
148149 .await?;
149150
151 license::sync_repo_license(&txn, &self.repo_dir(), &self.repo).await?;
152
150153 txn.commit().await.map_err(SyncError::Db)?;
151154
152155 let gc_timeout = self.settings.git_command_timeout_secs.max(1800);
Modified lib/git/src/openapi.rs View lib/git/src/openapi.rs
@@ -52,6 +52,9 @@
5252 handler::diffs::stats::diff_stats,
5353 handler::diffs::tree::diff_tree,
5454 handler::hooks::execute_script::execute_script,
55 handler::licenses::get_license,
56 handler::licenses::list_licenses,
57 handler::licenses::upsert_license,
5558 handler::lfs::cleanup_unreferenced::cleanup_unreferenced,
5659 handler::lfs::list_locks::list_locks,
5760 handler::lfs::lock::lock,
@@ -95,6 +98,10 @@
9598 handler::repos::update::update_repo,
9699 handler::signatures::verify_commit::verify_commit,
97100 handler::signatures::verify_tag::verify_tag,
101 handler::stars::is_starred,
102 handler::stars::list_stargazers,
103 handler::stars::star_repo,
104 handler::stars::unstar_repo,
98105 handler::submodules::list::submodule_list,
99106 handler::submodules::resolve::submodule_resolve,
100107 handler::tags::create::create_tag,
@@ -102,16 +109,35 @@
102109 handler::tags::get::get_tag,
103110 handler::tags::list::list_tags,
104111 handler::tags::verify::verify_tag,
112 handler::topics::add_topic_to_repo,
113 handler::topics::create_topic,
114 handler::topics::get_topic,
115 handler::topics::list_repo_topics,
116 handler::topics::list_repos_by_topic,
117 handler::topics::list_topics,
118 handler::topics::remove_topic_from_repo,
105119 handler::trees::follow::tree_follow,
106120 handler::trees::last_updated::tree_last_updated,
107121 handler::trees::read::tree_read,
108122 handler::trees::resolve_path::tree_resolve_path,
109123 handler::trees::walk::tree_walk,
124 handler::watches::get_watch_mode,
125 handler::watches::list_watchers,
126 handler::watches::unwatch,
127 handler::watches::upsert_watch,
128 handler::whitelists::add_branch_whitelist,
129 handler::whitelists::add_tag_whitelist,
130 handler::whitelists::list_branch_whitelists,
131 handler::whitelists::list_tag_whitelists,
132 handler::whitelists::remove_branch_whitelist,
133 handler::whitelists::remove_tag_whitelist,
110134 ),
111135 components(schemas(
112136 export::rest::NoData,
137 models::common::watch_mode::WatchMode,
113138 models::git::git_commit_status::CommitStatusState,
114139 models::git::git_tags::TagType,
140 models::repo::repo_branch_protection_whitelists::WhitelistKind,
115141 crate::lfs::types::LfsLock,
116142 crate::lfs::types::LfsLockOwner,
117143 crate::lfs::types::LfsLockListResponse,
@@ -139,6 +165,8 @@
139165 crate::service::content::WriteObjectResultDto,
140166 crate::service::diffs::DiffTreeRecordDto,
141167 crate::service::hooks::ScriptOutputDto,
168 crate::service::licenses::LicenseDto,
169 crate::service::licenses::UpsertLicenseCmd,
142170 crate::service::maintenance::FsckDto,
143171 crate::service::maintenance::PruneResultDto,
144172 crate::service::packs::IngestPackDto,
@@ -161,10 +189,23 @@
161189 crate::service::repos::RepoDto,
162190 crate::service::repos::UpdateRepoCmd,
163191 crate::service::signatures::VerifyResultDto,
192 crate::service::stars::RepoStarDto,
193 crate::service::stars::RepoStarResp,
194 crate::service::stars::StargazerDto,
164195 crate::service::submodules::SubmoduleDto,
165196 crate::service::tags::CreateTagCmd,
166197 crate::service::tags::TagDto,
198 crate::service::topics::CreateTopicCmd,
199 crate::service::topics::TopicDto,
200 crate::service::topics::TopicRepoDto,
167201 crate::service::trees::PathLastUpdated,
202 crate::service::watches::RepoWatchDto,
203 crate::service::watches::UpsertWatchCmd,
204 crate::service::watches::WatcherDto,
205 crate::service::whitelists::AddBranchWhitelistCmd,
206 crate::service::whitelists::AddTagWhitelistCmd,
207 crate::service::whitelists::BranchWhitelistDto,
208 crate::service::whitelists::TagWhitelistDto,
168209 crate::handler::commits::is_ancestor::IsAncestorResponse,
169210 crate::handler::commits::sync_commit::CommitSyncResponse,
170211 crate::handler::conflicts::check_threeway::ConflictCheckThreewayBody,
@@ -185,6 +226,7 @@
185226 crate::handler::remotes::clone_import::RemoteImportBody,
186227 crate::handler::remotes::fetch::RemoteFetchBody,
187228 crate::handler::remotes::push::RemotePushBody,
229 crate::handler::stars::StarStatusResp,
188230 crate::handler::tags::verify::TagVerifyDto,
189231 ))
190232)]
Modified lib/git/src/routes.rs View lib/git/src/routes.rs
@@ -1,4 +1,4 @@
1use axum::routing::{delete, get, patch, post};
1use axum::routing::{delete, get, patch, post, put};
22use axum::{Extension, Router};
33use crate::handler::analysis::code_activity::code_activity;
44use crate::handler::analysis::contributors::contributors;
@@ -101,6 +101,17 @@
101101use crate::handler::trees::read::tree_read;
102102use crate::handler::trees::resolve_path::tree_resolve_path;
103103use crate::handler::trees::walk::tree_walk;
104use crate::handler::licenses::{get_license, list_licenses, upsert_license};
105use crate::handler::stars::{is_starred, list_stargazers, star_repo, unstar_repo};
106use crate::handler::topics::{
107 add_topic_to_repo, create_topic, get_topic, list_repo_topics, list_repos_by_topic, list_topics,
108 remove_topic_from_repo,
109};
110use crate::handler::watches::{get_watch_mode, list_watchers, unwatch, upsert_watch};
111use crate::handler::whitelists::{
112 add_branch_whitelist, add_tag_whitelist, list_branch_whitelists, list_tag_whitelists,
113 remove_branch_whitelist, remove_tag_whitelist,
114};
104115use crate::service::GitService;
105116
106117pub fn router(service: GitService) -> Router<GitService> {
@@ -108,6 +119,8 @@
108119 Router::new()
109120 .route("/graphql", post(graphql_post).get(graphql_get))
110121 .nest("/repos", repos_router())
122 .nest("/topics", topics_router())
123 .nest("/licenses", licenses_router())
111124 .layer(Extension(schema))
112125}
113126
@@ -416,4 +429,57 @@
416429 "/{space_name}/{repo_name}/archives/bundle",
417430 post(create_bundle),
418431 )
432 .route(
433 "/{space_name}/{repo_name}/star",
434 put(star_repo).delete(unstar_repo).get(is_starred),
435 )
436 .route(
437 "/{space_name}/{repo_name}/stargazers",
438 get(list_stargazers),
439 )
440 .route(
441 "/{space_name}/{repo_name}/watch",
442 put(upsert_watch).delete(unwatch).get(get_watch_mode),
443 )
444 .route(
445 "/{space_name}/{repo_name}/watchers",
446 get(list_watchers),
447 )
448 .route(
449 "/{space_name}/{repo_name}/topics",
450 get(list_repo_topics),
451 )
452 .route(
453 "/{space_name}/{repo_name}/topics/{topic_name}",
454 put(add_topic_to_repo).delete(remove_topic_from_repo),
455 )
456 .route(
457 "/{space_name}/{repo_name}/branch-protection/{policy_id}/whitelists",
458 post(add_branch_whitelist).get(list_branch_whitelists),
459 )
460 .route(
461 "/{space_name}/{repo_name}/branch-protection/{policy_id}/whitelists/{whitelist_id}",
462 delete(remove_branch_whitelist),
463 )
464 .route(
465 "/{space_name}/{repo_name}/tag-protection/{policy_id}/whitelists",
466 post(add_tag_whitelist).get(list_tag_whitelists),
467 )
468 .route(
469 "/{space_name}/{repo_name}/tag-protection/{policy_id}/whitelists/{user_id}",
470 delete(remove_tag_whitelist),
471 )
472}
473
474fn topics_router() -> Router<GitService> {
475 Router::new()
476 .route("/", post(create_topic).get(list_topics))
477 .route("/{name}", get(get_topic))
478 .route("/{name}/repos", get(list_repos_by_topic))
479}
480
481fn licenses_router() -> Router<GitService> {
482 Router::new()
483 .route("/", get(list_licenses))
484 .route("/{name}", get(get_license).put(upsert_license))
419485}
No newline at end of file