Skip to content

Commit 40a7708

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 40a7708

File tree

9 files changed

+308
-4
lines changed

9 files changed

+308
-4
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: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ 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+
NO_ACCESS = 0,
16+
MINIMAL_ACCESS = 5,
17+
GUEST = 10,
18+
REPORTER = 20,
19+
DEVELOPER = 30,
20+
MAINTAINER = 40,
21+
OWNER = 50,
22+
ADMIN = 60,
23+
}
24+
25+
type ProjectsAccessLevel = Exclude<AccessLevel, AccessLevel.ADMIN>;
26+
1427
export const createGitLabFromPersonalAccessToken = async ({ token, url }: { token?: string, url?: string }) => {
1528
const isGitLabCloud = url ? new URL(url).hostname === GITLAB_CLOUD_HOSTNAME : true;
1629
return new Gitlab({
@@ -48,6 +61,16 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =
4861
token,
4962
url: config.url,
5063
});
64+
const minAccessLevel: ProjectsAccessLevel | undefined = config.minAccessLevel;
65+
const projectListOptions = {
66+
perPage: 100,
67+
...(minAccessLevel !== undefined ? {
68+
minAccessLevel,
69+
} : {}),
70+
...(config.exclude?.size ? {
71+
statistics: true,
72+
} : {}),
73+
};
5174

5275
let allRepos: ProjectSchema[] = [];
5376
let allWarnings: string[] = [];
@@ -58,7 +81,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =
5881
logger.debug(`Fetching all projects visible in ${config.url}...`);
5982
const { durationMs, data: _projects } = await measure(async () => {
6083
const fetchFn = () => api.Projects.all({
61-
perPage: 100,
84+
...projectListOptions,
6285
});
6386
return fetchWithRetry(fetchFn, `all projects in ${config.url}`, logger);
6487
});
@@ -82,8 +105,8 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =
82105
logger.debug(`Fetching project info for group ${group}...`);
83106
const { durationMs, data } = await measure(async () => {
84107
const fetchFn = () => api.Groups.allProjects(group, {
85-
perPage: 100,
86-
includeSubgroups: true
108+
...projectListOptions,
109+
includeSubgroups: true,
87110
});
88111
return fetchWithRetry(fetchFn, `group ${group}`, logger);
89112
});
@@ -123,7 +146,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig) =
123146
logger.debug(`Fetching project info for user ${user}...`);
124147
const { durationMs, data } = await measure(async () => {
125148
const fetchFn = () => api.Users.allProjects(user, {
126-
perPage: 100,
149+
...projectListOptions,
127150
});
128151
return fetchWithRetry(fetchFn, `user ${user}`, logger);
129152
});
@@ -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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,19 @@ 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+
0,
297+
5,
298+
10,
299+
20,
300+
30,
301+
40,
302+
50
303+
],
304+
"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. Note: GitLab project listing APIs do not accept 60 (Admin) for this field."
305+
},
293306
"projects": {
294307
"type": "array",
295308
"items": {
@@ -362,6 +375,21 @@ const schema = {
362375
"ci"
363376
]
364377
]
378+
},
379+
"size": {
380+
"type": "object",
381+
"description": "Exclude projects based on GitLab statistics size fields (in bytes).",
382+
"properties": {
383+
"min": {
384+
"type": "integer",
385+
"description": "Minimum project size (in bytes) to sync (inclusive). Projects smaller than this will be excluded."
386+
},
387+
"max": {
388+
"type": "integer",
389+
"description": "Maximum project size (in bytes) to sync (inclusive). Projects larger than this will be excluded."
390+
}
391+
},
392+
"additionalProperties": false
365393
}
366394
},
367395
"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. Note: GitLab project listing APIs do not accept 60 (Admin) for this field.
140+
*/
141+
minAccessLevel?: 0 | 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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ 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+
0,
85+
5,
86+
10,
87+
20,
88+
30,
89+
40,
90+
50
91+
],
92+
"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. Note: GitLab project listing APIs do not accept 60 (Admin) for this field."
93+
},
8194
"projects": {
8295
"type": "array",
8396
"items": {
@@ -150,6 +163,21 @@ const schema = {
150163
"ci"
151164
]
152165
]
166+
},
167+
"size": {
168+
"type": "object",
169+
"description": "Exclude projects based on GitLab statistics size fields (in bytes).",
170+
"properties": {
171+
"min": {
172+
"type": "integer",
173+
"description": "Minimum project size (in bytes) to sync (inclusive). Projects smaller than this will be excluded."
174+
},
175+
"max": {
176+
"type": "integer",
177+
"description": "Maximum project size (in bytes) to sync (inclusive). Projects larger than this will be excluded."
178+
}
179+
},
180+
"additionalProperties": false
153181
}
154182
},
155183
"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. Note: GitLab project listing APIs do not accept 60 (Admin) for this field.
42+
*/
43+
minAccessLevel?: 0 | 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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,19 @@ 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+
0,
712+
5,
713+
10,
714+
20,
715+
30,
716+
40,
717+
50
718+
],
719+
"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. Note: GitLab project listing APIs do not accept 60 (Admin) for this field."
720+
},
708721
"projects": {
709722
"type": "array",
710723
"items": {
@@ -777,6 +790,21 @@ const schema = {
777790
"ci"
778791
]
779792
]
793+
},
794+
"size": {
795+
"type": "object",
796+
"description": "Exclude projects based on GitLab statistics size fields (in bytes).",
797+
"properties": {
798+
"min": {
799+
"type": "integer",
800+
"description": "Minimum project size (in bytes) to sync (inclusive). Projects smaller than this will be excluded."
801+
},
802+
"max": {
803+
"type": "integer",
804+
"description": "Maximum project size (in bytes) to sync (inclusive). Projects larger than this will be excluded."
805+
}
806+
},
807+
"additionalProperties": false
780808
}
781809
},
782810
"additionalProperties": false

0 commit comments

Comments
 (0)