Skip to content

Commit a65ffd8

Browse files
committed
Issue 564: add size filtering and typed min access level
Support excluding GitLab projects by statistics-backed size bounds and pass through minAccessLevel for project listing with AccessLevel-aligned typing.
1 parent 06acea2 commit a65ffd8

File tree

9 files changed

+305
-5
lines changed

9 files changed

+305
-5
lines changed

packages/backend/src/gitlab.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,73 @@ test('shouldExcludeProject returns false when exclude.userOwnedProjects is true
6868
exclude: { userOwnedProjects: true },
6969
})).toBe(false);
7070
});
71+
72+
test('shouldExcludeProject returns true when project size is less than exclude.size.min.', () => {
73+
const project = {
74+
path_with_namespace: 'test/project',
75+
statistics: {
76+
storage_size: 99,
77+
},
78+
} as unknown as ProjectSchema;
79+
80+
expect(shouldExcludeProject({
81+
project,
82+
exclude: {
83+
size: {
84+
min: 100,
85+
},
86+
},
87+
})).toBe(true);
88+
});
89+
90+
test('shouldExcludeProject returns true when project size is greater than exclude.size.max.', () => {
91+
const project = {
92+
path_with_namespace: 'test/project',
93+
statistics: {
94+
storage_size: 101,
95+
},
96+
} as unknown as ProjectSchema;
97+
98+
expect(shouldExcludeProject({
99+
project,
100+
exclude: {
101+
size: {
102+
max: 100,
103+
},
104+
},
105+
})).toBe(true);
106+
});
107+
108+
test('shouldExcludeProject returns false when project size is within exclude.size bounds.', () => {
109+
const project = {
110+
path_with_namespace: 'test/project',
111+
statistics: {
112+
storage_size: 100,
113+
},
114+
} as unknown as ProjectSchema;
115+
116+
expect(shouldExcludeProject({
117+
project,
118+
exclude: {
119+
size: {
120+
min: 100,
121+
max: 100,
122+
},
123+
},
124+
})).toBe(false);
125+
});
126+
127+
test('shouldExcludeProject returns false when exclude.size is set but project statistics are unavailable.', () => {
128+
const project = {
129+
path_with_namespace: 'test/project',
130+
} as ProjectSchema;
131+
132+
expect(shouldExcludeProject({
133+
project,
134+
exclude: {
135+
size: {
136+
min: 100,
137+
},
138+
},
139+
})).toBe(false);
140+
});

packages/backend/src/gitlab.ts

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ import { fetchWithRetry, measure } from "./utils.js";
1111
const logger = createLogger('gitlab');
1212
export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
1313

14+
export enum AccessLevel {
15+
MINIMAL_ACCESS = 5,
16+
GUEST = 10,
17+
REPORTER = 20,
18+
DEVELOPER = 30,
19+
MAINTAINER = 40,
20+
OWNER = 50,
21+
}
22+
23+
type ProjectsAccessLevel = AccessLevel;
24+
1425
export const createGitLabFromPersonalAccessToken = async ({ token, url }: { token?: string, url?: string }) => {
1526
const isGitLabCloud = url ? new URL(url).hostname === GITLAB_CLOUD_HOSTNAME : true;
1627
return new Gitlab({
@@ -48,6 +59,16 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =
4859
token,
4960
url: config.url,
5061
});
62+
const minAccessLevel: ProjectsAccessLevel | undefined = config.minAccessLevel;
63+
const projectListOptions = {
64+
perPage: 100,
65+
...(minAccessLevel !== undefined ? {
66+
minAccessLevel,
67+
} : {}),
68+
...(config.exclude?.size ? {
69+
statistics: true,
70+
} : {}),
71+
};
5172

5273
let allRepos: ProjectSchema[] = [];
5374
let allWarnings: string[] = [];
@@ -58,7 +79,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =
5879
logger.debug(`Fetching all projects visible in ${config.url}...`);
5980
const { durationMs, data: _projects } = await measure(async () => {
6081
const fetchFn = () => api.Projects.all({
61-
perPage: 100,
82+
...projectListOptions,
6283
});
6384
return fetchWithRetry(fetchFn, `all projects in ${config.url}`, logger);
6485
});
@@ -82,8 +103,8 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =
82103
logger.debug(`Fetching project info for group ${group}...`);
83104
const { durationMs, data } = await measure(async () => {
84105
const fetchFn = () => api.Groups.allProjects(group, {
85-
perPage: 100,
86-
includeSubgroups: true
106+
...projectListOptions,
107+
includeSubgroups: true,
87108
});
88109
return fetchWithRetry(fetchFn, `group ${group}`, logger);
89110
});
@@ -123,7 +144,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =
123144
logger.debug(`Fetching project info for user ${user}...`);
124145
const { durationMs, data } = await measure(async () => {
125146
const fetchFn = () => api.Users.allProjects(user, {
126-
perPage: 100,
147+
...projectListOptions,
127148
});
128149
return fetchWithRetry(fetchFn, `user ${user}`, logger);
129150
});
@@ -162,7 +183,9 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =
162183
try {
163184
logger.debug(`Fetching project info for project ${project}...`);
164185
const { durationMs, data } = await measure(async () => {
165-
const fetchFn = () => api.Projects.show(project);
186+
const fetchFn = () => api.Projects.show(project, {
187+
statistics: config.exclude?.size ? true : undefined,
188+
});
166189
return fetchWithRetry(fetchFn, `project ${project}`, logger);
167190
});
168191
logger.debug(`Found project ${project} in ${durationMs}ms.`);
@@ -253,6 +276,21 @@ export const shouldExcludeProject = ({
253276
}
254277
}
255278

279+
if (exclude?.size) {
280+
const projectSizeBytes = getProjectSizeBytes(project);
281+
if (projectSizeBytes !== undefined) {
282+
if (exclude.size.min !== undefined && projectSizeBytes < exclude.size.min) {
283+
reason = `project size (${projectSizeBytes}) is less than \`exclude.size.min\` (${exclude.size.min})`;
284+
return true;
285+
}
286+
287+
if (exclude.size.max !== undefined && projectSizeBytes > exclude.size.max) {
288+
reason = `project size (${projectSizeBytes}) is greater than \`exclude.size.max\` (${exclude.size.max})`;
289+
return true;
290+
}
291+
}
292+
}
293+
256294
if (include?.topics) {
257295
const configTopics = include.topics.map(topic => topic.toLowerCase());
258296
const projectTopics = project.topics ?? [];
@@ -284,6 +322,39 @@ export const shouldExcludeProject = ({
284322
return false;
285323
}
286324

325+
const getProjectSizeBytes = (project: ProjectSchema): number | undefined => {
326+
// GitLab's API returns size data in the statistics object when `statistics=true`.
327+
// We support both snake_case and camelCase keys to be resilient to response typing differences.
328+
const projectWithStats = project as ProjectSchema & {
329+
statistics?: {
330+
storage_size?: number;
331+
repository_size?: number;
332+
storageSize?: number;
333+
repositorySize?: number;
334+
};
335+
};
336+
337+
const statistics = projectWithStats.statistics;
338+
if (!statistics) {
339+
return;
340+
}
341+
342+
if (typeof statistics.storage_size === "number") {
343+
return statistics.storage_size;
344+
}
345+
if (typeof statistics.repository_size === "number") {
346+
return statistics.repository_size;
347+
}
348+
if (typeof statistics.storageSize === "number") {
349+
return statistics.storageSize;
350+
}
351+
if (typeof statistics.repositorySize === "number") {
352+
return statistics.repositorySize;
353+
}
354+
355+
return;
356+
}
357+
287358
export const getProjectMembers = async (projectId: string, api: InstanceType<typeof Gitlab>) => {
288359
try {
289360
const fetchFn = () => api.ProjectMembers.all(projectId, {

packages/schemas/src/v3/connection.schema.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,18 @@ const schema = {
290290
],
291291
"description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)."
292292
},
293+
"minAccessLevel": {
294+
"type": "integer",
295+
"enum": [
296+
5,
297+
10,
298+
20,
299+
30,
300+
40,
301+
50
302+
],
303+
"description": "Minimum GitLab access level required for projects to be returned. Uses GitLab role levels where 20=Reporter, 30=Developer, 40=Maintainer, and 50=Owner."
304+
},
293305
"projects": {
294306
"type": "array",
295307
"items": {
@@ -362,6 +374,21 @@ const schema = {
362374
"ci"
363375
]
364376
]
377+
},
378+
"size": {
379+
"type": "object",
380+
"description": "Exclude projects based on GitLab statistics size fields (in bytes).",
381+
"properties": {
382+
"min": {
383+
"type": "integer",
384+
"description": "Minimum project size (in bytes) to sync (inclusive). Projects smaller than this will be excluded."
385+
},
386+
"max": {
387+
"type": "integer",
388+
"description": "Maximum project size (in bytes) to sync (inclusive). Projects larger than this will be excluded."
389+
}
390+
},
391+
"additionalProperties": false
365392
}
366393
},
367394
"additionalProperties": false

packages/schemas/src/v3/connection.type.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ export interface GitlabConnectionConfig {
135135
* List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`).
136136
*/
137137
groups?: string[];
138+
/**
139+
* Minimum GitLab access level required for projects to be returned. Uses GitLab role levels where 20=Reporter, 30=Developer, 40=Maintainer, and 50=Owner.
140+
*/
141+
minAccessLevel?: 5 | 10 | 20 | 30 | 40 | 50;
138142
/**
139143
* List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/
140144
*/
@@ -166,6 +170,19 @@ export interface GitlabConnectionConfig {
166170
* List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.
167171
*/
168172
topics?: string[];
173+
/**
174+
* Exclude projects based on GitLab statistics size fields (in bytes).
175+
*/
176+
size?: {
177+
/**
178+
* Minimum project size (in bytes) to sync (inclusive). Projects smaller than this will be excluded.
179+
*/
180+
min?: number;
181+
/**
182+
* Maximum project size (in bytes) to sync (inclusive). Projects larger than this will be excluded.
183+
*/
184+
max?: number;
185+
};
169186
};
170187
revisions?: GitRevisions;
171188
}

packages/schemas/src/v3/gitlab.schema.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ const schema = {
7878
],
7979
"description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)."
8080
},
81+
"minAccessLevel": {
82+
"type": "integer",
83+
"enum": [
84+
5,
85+
10,
86+
20,
87+
30,
88+
40,
89+
50
90+
],
91+
"description": "Minimum GitLab access level required for projects to be returned. Uses GitLab role levels where 20=Reporter, 30=Developer, 40=Maintainer, and 50=Owner."
92+
},
8193
"projects": {
8294
"type": "array",
8395
"items": {
@@ -150,6 +162,21 @@ const schema = {
150162
"ci"
151163
]
152164
]
165+
},
166+
"size": {
167+
"type": "object",
168+
"description": "Exclude projects based on GitLab statistics size fields (in bytes).",
169+
"properties": {
170+
"min": {
171+
"type": "integer",
172+
"description": "Minimum project size (in bytes) to sync (inclusive). Projects smaller than this will be excluded."
173+
},
174+
"max": {
175+
"type": "integer",
176+
"description": "Maximum project size (in bytes) to sync (inclusive). Projects larger than this will be excluded."
177+
}
178+
},
179+
"additionalProperties": false
153180
}
154181
},
155182
"additionalProperties": false

packages/schemas/src/v3/gitlab.type.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export interface GitlabConnectionConfig {
3737
* List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`).
3838
*/
3939
groups?: string[];
40+
/**
41+
* Minimum GitLab access level required for projects to be returned. Uses GitLab role levels where 20=Reporter, 30=Developer, 40=Maintainer, and 50=Owner.
42+
*/
43+
minAccessLevel?: 5 | 10 | 20 | 30 | 40 | 50;
4044
/**
4145
* List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/
4246
*/
@@ -68,6 +72,19 @@ export interface GitlabConnectionConfig {
6872
* List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.
6973
*/
7074
topics?: string[];
75+
/**
76+
* Exclude projects based on GitLab statistics size fields (in bytes).
77+
*/
78+
size?: {
79+
/**
80+
* Minimum project size (in bytes) to sync (inclusive). Projects smaller than this will be excluded.
81+
*/
82+
min?: number;
83+
/**
84+
* Maximum project size (in bytes) to sync (inclusive). Projects larger than this will be excluded.
85+
*/
86+
max?: number;
87+
};
7188
};
7289
revisions?: GitRevisions;
7390
}

packages/schemas/src/v3/index.schema.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,18 @@ const schema = {
705705
],
706706
"description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)."
707707
},
708+
"minAccessLevel": {
709+
"type": "integer",
710+
"enum": [
711+
5,
712+
10,
713+
20,
714+
30,
715+
40,
716+
50
717+
],
718+
"description": "Minimum GitLab access level required for projects to be returned. Uses GitLab role levels where 20=Reporter, 30=Developer, 40=Maintainer, and 50=Owner."
719+
},
708720
"projects": {
709721
"type": "array",
710722
"items": {
@@ -777,6 +789,21 @@ const schema = {
777789
"ci"
778790
]
779791
]
792+
},
793+
"size": {
794+
"type": "object",
795+
"description": "Exclude projects based on GitLab statistics size fields (in bytes).",
796+
"properties": {
797+
"min": {
798+
"type": "integer",
799+
"description": "Minimum project size (in bytes) to sync (inclusive). Projects smaller than this will be excluded."
800+
},
801+
"max": {
802+
"type": "integer",
803+
"description": "Maximum project size (in bytes) to sync (inclusive). Projects larger than this will be excluded."
804+
}
805+
},
806+
"additionalProperties": false
780807
}
781808
},
782809
"additionalProperties": false

0 commit comments

Comments
 (0)